Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 8 additions & 3 deletions packages/app/src/electron/ai/__tests__/codex-cli-mcp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,14 +22,12 @@ const mocks = vi.hoisted(() => {
model,
provider,
createCodexAppServer: vi.fn(() => provider),
createSdkMcpServer: vi.fn(() => ({ url: "http://127.0.0.1/mcp" })),
tool: vi.fn((definition) => definition),
};
});

vi.mock("ai-sdk-provider-codex-cli", () => ({
createCodexAppServer: mocks.createCodexAppServer,
createSdkMcpServer: mocks.createSdkMcpServer,
tool: mocks.tool,
}));

Expand Down Expand Up @@ -90,7 +88,14 @@ describe("CodexCliAdapter MCP transport", () => {
requestInteraction: vi.fn(async () => ({ approved: false })),
});

const mcpServer = mocks.createSdkMcpServer.mock.results[0].value;
const mcpServer = providerSettings()?.mcpServers?.convera;
expect(mcpServer).toEqual(
expect.objectContaining({
name: "convera",
_start: expect.any(Function),
_stop: expect.any(Function),
}),
);
expect(mocks.provider).toHaveBeenCalledWith(
"gpt-test",
expect.objectContaining({
Expand Down
13 changes: 2 additions & 11 deletions packages/app/src/electron/ai/providers/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
resolveLocalModelId,
type LocalAiProviderAdapter,
} from "../provider-adapter";
import { toMcpToolResult } from "../tool-result";
import type { LocalAiProviderStatus } from "../types";

export class ClaudeCodeAdapter implements LocalAiProviderAdapter {
Expand Down Expand Up @@ -52,17 +53,7 @@ export class ClaudeCodeAdapter implements LocalAiProviderAdapter {
async (input) => {
try {
const output = await definition.execute(input);
return {
content: [
{
type: "text" as const,
text:
typeof output === "string"
? output
: JSON.stringify(output),
},
],
};
return toMcpToolResult(output);
} catch (error) {
return {
content: [
Expand Down
10 changes: 7 additions & 3 deletions packages/app/src/electron/ai/providers/codex-cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
type LocalAiProviderAdapter,
} from "../provider-adapter";
import type { LocalAiProviderStatus } from "../types";
import { createCodexMcpServer } from "./codex-mcp-server";

export class CodexCliAdapter implements LocalAiProviderAdapter {
readonly id = "codex-cli" as const;
Expand Down Expand Up @@ -56,8 +57,7 @@ export class CodexCliAdapter implements LocalAiProviderAdapter {
context: Parameters<LocalAiProviderAdapter["createModel"]>[2],
): Promise<LanguageModel> {
await this.ensureProvider(status.executablePath);
const { createSdkMcpServer, tool } =
await importCodexProviderWithZod3Compatibility();
const { tool } = await importCodexProviderWithZod3Compatibility();
const tools = context.tools.map((definition) =>
tool({
name: definition.name,
Expand All @@ -69,7 +69,11 @@ export class CodexCliAdapter implements LocalAiProviderAdapter {
);
const mcpServer =
tools.length > 0
? createSdkMcpServer({ name: "convera", tools })
? createCodexMcpServer({
name: "convera",
tools,
definitions: context.tools,
})
: undefined;
const requestApproval = async (
name: string,
Expand Down
89 changes: 89 additions & 0 deletions packages/app/src/electron/ai/providers/codex-mcp-server.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
import type { LocalTool } from "ai-sdk-provider-codex-cli";
import { describe, expect, it, vi } from "vitest";
import { z } from "zod";
import type { AgentTool } from "../agent-tools";
import { createCodexMcpServer } from "./codex-mcp-server";

describe("Codex image-capable MCP server", () => {
it("preserves image content and rejects unauthenticated callers", async () => {
const execute = vi.fn(async () => ({
content: [
{ type: "text" as const, text: "screen" },
{ type: "image" as const, data: "cG5n", mimeType: "image/png" },
],
}));
const definition: AgentTool = {
name: "builtin__computer_control",
qualifiedName: "builtin:computer_control",
description: "Capture the screen",
inputSchema: {
type: "object",
properties: { action: { type: "string" } },
},
inputShape: { action: z.string() },
inputValidator: z.object({ action: z.string() }),
execute,
};
const localTool: LocalTool = {
name: definition.name,
description: definition.description,
inputSchema: definition.inputSchema,
execute,
};
const server = createCodexMcpServer({
name: "convera",
tools: [localTool],
definitions: [definition],
});

const config = await server._start();
expect(config.transport).toBe("http");
if (config.transport !== "http") {
throw new Error("Expected HTTP MCP transport.");
}

const unauthorized = await fetch(config.url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
jsonrpc: "2.0",
id: 1,
method: "initialize",
params: {
protocolVersion: "2024-11-05",
capabilities: {},
clientInfo: { name: "test", version: "1.0.0" },
},
}),
});
expect(unauthorized.status).toBe(401);

const client = new Client({ name: "test", version: "1.0.0" });
const transport = new StreamableHTTPClientTransport(new URL(config.url), {
requestInit: {
headers: { Authorization: `Bearer ${config.bearerToken}` },
},
});

try {
await client.connect(transport);
await expect(
client.callTool({
name: definition.name,
arguments: { action: "screenshot" },
}),
).resolves.toEqual({
content: [
{ type: "text", text: "screen" },
{ type: "image", data: "cG5n", mimeType: "image/png" },
],
});
expect(execute).toHaveBeenCalledWith({ action: "screenshot" });
} finally {
await client.close();
await server._stop();
}
});
});
176 changes: 176 additions & 0 deletions packages/app/src/electron/ai/providers/codex-mcp-server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import type { ServerResponse } from "node:http";
import { createServer, type Server } from "node:http";
import { randomBytes } from "node:crypto";
import type { LocalTool, SdkMcpServer } from "ai-sdk-provider-codex-cli";
import type { AgentTool } from "../agent-tools";
import { toMcpToolResult } from "../tool-result";

const SDK_MCP_SERVER_MARKER = Symbol.for(
"ai-sdk-provider-codex-cli.sdkMcpServer",
);

function sendJson(
response: ServerResponse,
statusCode: number,
body: unknown,
): void {
response.writeHead(statusCode, { "Content-Type": "application/json" });
response.end(JSON.stringify(body));
}

async function closeHttpServer(server: Server): Promise<void> {
if (!server.listening) return;
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}

async function waitForResponse(response: ServerResponse): Promise<void> {
if (response.writableFinished || response.destroyed) return;
await new Promise<void>((resolve) => {
response.once("finish", resolve);
response.once("close", resolve);
});
}

function createRequestServer(
name: string,
definitions: AgentTool[],
): McpServer {
const server = new McpServer({ name, version: "1.0.0" });
for (const definition of definitions) {
server.registerTool(
definition.name,
{
description: definition.description,
inputSchema: definition.inputShape,
},
async (input) =>
toMcpToolResult(
await definition.execute(input as Record<string, unknown>),
),
);
}
return server;
}

export function createCodexMcpServer(options: {
name: string;
tools: LocalTool[];
definitions: AgentTool[];
}): SdkMcpServer {
const bearerToken = randomBytes(32).toString("hex");
const expectedAuthorization = `Bearer ${bearerToken}`;
let httpServer: Server | undefined;
let startPromise: Promise<{
transport: "http";
url: string;
bearerToken: string;
}> | null = null;
let stopPromise: Promise<void> | null = null;

const sdkServer = {
[SDK_MCP_SERVER_MARKER]: true,
name: options.name,
tools: options.tools,
async _start() {
while (stopPromise) await stopPromise;
if (httpServer?.listening) {
const address = httpServer.address();
if (address && typeof address !== "string") {
return {
transport: "http" as const,
url: `http://127.0.0.1:${address.port}/mcp`,
bearerToken,
};
}
}
if (startPromise) return startPromise;

startPromise = new Promise((resolve, reject) => {
const server = createServer(async (request, response) => {
if (request.url !== "/mcp") {
sendJson(response, 404, { error: "Not found" });
return;
}
if (request.method !== "POST") {
sendJson(response, 405, { error: "Method not allowed" });
return;
}
if (request.headers.authorization !== expectedAuthorization) {
response.setHeader("WWW-Authenticate", "Bearer");
sendJson(response, 401, { error: "Unauthorized" });
return;
}

const mcpServer = createRequestServer(
options.name,
options.definitions,
);
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
enableJsonResponse: true,
});
try {
await mcpServer.connect(transport);
await transport.handleRequest(request, response);
await waitForResponse(response);
} catch (error) {
if (!response.headersSent) {
sendJson(response, 500, {
jsonrpc: "2.0",
id: null,
error: {
code: -32603,
message:
error instanceof Error ? error.message : String(error),
},
});
}
} finally {
await mcpServer.close();
}
});
server.once("error", reject);
server.listen(0, "127.0.0.1", () => {
server.off("error", reject);
const address = server.address();
if (!address || typeof address === "string") {
reject(new Error("Failed to resolve local MCP server address."));
return;
}
httpServer = server;
resolve({
transport: "http",
url: `http://127.0.0.1:${address.port}/mcp`,
bearerToken,
});
});
});

try {
return await startPromise;
} finally {
startPromise = null;
}
},
async _stop() {
if (stopPromise) return stopPromise;
stopPromise = (async () => {
await startPromise?.catch(() => undefined);
const server = httpServer;
httpServer = undefined;
if (server) await closeHttpServer(server);
})();
try {
await stopPromise;
} finally {
stopPromise = null;
}
},
};

return sdkServer as unknown as SdkMcpServer;
}
22 changes: 22 additions & 0 deletions packages/app/src/electron/ai/tool-result.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { isMcpToolResult, toMcpToolResult } from "./tool-result";

describe("tool result conversion", () => {
it("preserves MCP image content", () => {
const result = {
content: [
{ type: "text" as const, text: "screen" },
{ type: "image" as const, data: "cG5n", mimeType: "image/png" },
],
};

expect(isMcpToolResult(result)).toBe(true);
expect(toMcpToolResult(result)).toBe(result);
});

it("converts ordinary tool output to MCP text content", () => {
expect(toMcpToolResult({ success: true })).toEqual({
content: [{ type: "text", text: '{"success":true}' }],
});
});
});
Loading
Loading