Skip to content

Commit 92e3f9f

Browse files
committed
Add Chrome and OpenAI developer docs as Codex plugin presets
1 parent 3d8c14c commit 92e3f9f

15 files changed

Lines changed: 717 additions & 63 deletions

e2e/local/codex-plugins.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ import { withLocalServer } from "./local-server";
3434
const api = composePluginApi([mcpHttpPlugin()] as const);
3535

3636
const FIXTURE = fileURLToPath(new URL("./fixtures/stdio-mcp-server.mjs", import.meta.url));
37+
const CHROME_CLIENT_RELATIVE = join(
38+
"plugins",
39+
"cache",
40+
"openai-bundled",
41+
"chrome",
42+
"latest",
43+
"scripts",
44+
"browser-client.mjs",
45+
);
3746
const APP_SERVER_FIXTURE = fileURLToPath(
3847
new URL("./fixtures/codex-app-server.mjs", import.meta.url),
3948
);
@@ -67,6 +76,11 @@ const makeCodexHome = (): string => {
6776
mode: 0o755,
6877
});
6978

79+
// Chrome's bundled browser client, behind the `latest` symlink Codex keeps.
80+
const chromeClient = join(home, CHROME_CLIENT_RELATIVE);
81+
mkdirSync(join(chromeClient, ".."), { recursive: true });
82+
writeFileSync(chromeClient, "export const setupBrowserRuntime = async () => ({});\n");
83+
7084
const versionDir = join(home, "plugins", "cache", "personal", "echo-suite", "1.0.2");
7185
mkdirSync(join(versionDir, ".codex-plugin"), { recursive: true });
7286
mkdirSync(join(versionDir, "bin"), { recursive: true });
@@ -115,10 +129,12 @@ scenario(
115129
const { plugins } = yield* client.mcp.listCodexPlugins();
116130
const byId = new Map(plugins.map((plugin) => [plugin.id, plugin]));
117131
expect([...byId.keys()].sort(), "curated + scanned entries are reported").toEqual([
132+
"codex-chrome",
118133
"codex-computer-history",
119134
"codex-computer-use",
120135
"codex-echo-suite",
121136
"codex-messages",
137+
"codex-openai-docs",
122138
]);
123139
for (const plugin of plugins) {
124140
expect(plugin.available, `${plugin.id} is available`).toBe(true);
@@ -136,6 +152,18 @@ scenario(
136152
expect(messages?.appServer, "curated entries name their Codex server").toEqual({
137153
server: "messages",
138154
});
155+
// Computer Use and Chrome have no server of their own: both are
156+
// projected onto `node_repl`, and Chrome carries the client module
157+
// its surface imports, resolved through the `latest` symlink.
158+
expect(byId.get("codex-computer-use")?.appServer).toEqual({
159+
server: "node_repl",
160+
surface: "sky",
161+
});
162+
expect(byId.get("codex-chrome")?.appServer).toEqual({
163+
server: "node_repl",
164+
surface: "browser",
165+
modulePath: join(codexHome, CHROME_CLIENT_RELATIVE),
166+
});
139167

140168
// Add two entries exactly as the add-form's Codex-plugins card does:
141169
// the reported recipe, verbatim.

packages/plugins/mcp/src/api/group.ts

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,11 @@ const AddStdioServerPayload = Schema.Struct({
6666
/** Reach the server through the Codex app-server bridge: the command spawns
6767
* `codex app-server` and `server` names the MCP server inside Codex. */
6868
appServer: Schema.optional(
69-
Schema.Struct({ server: Schema.String, surface: Schema.optional(Schema.Literal("sky")) }),
69+
Schema.Struct({
70+
server: Schema.String,
71+
surface: Schema.optional(Schema.Literals(["sky", "browser"])),
72+
modulePath: Schema.optional(Schema.String),
73+
}),
7074
),
7175
slug: Schema.optional(Schema.String),
7276
});
@@ -152,7 +156,11 @@ const CodexPluginEntrySchema = Schema.Struct({
152156
/** Present on curated entries: add through the Codex app-server bridge,
153157
* calling tools on this named server inside Codex. */
154158
appServer: Schema.optional(
155-
Schema.Struct({ server: Schema.String, surface: Schema.optional(Schema.Literal("sky")) }),
159+
Schema.Struct({
160+
server: Schema.String,
161+
surface: Schema.optional(Schema.Literals(["sky", "browser"])),
162+
modulePath: Schema.optional(Schema.String),
163+
}),
156164
),
157165
setupHint: Schema.optional(Schema.String),
158166
/** The plugin's own icon from its local install, as a data URI. */

packages/plugins/mcp/src/api/handlers.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ const toServerInput = (
4141
env?: Record<string, string>;
4242
cwd?: string;
4343
versionNegotiation?: "legacy" | "auto";
44-
appServer?: { server: string; surface?: "sky" };
44+
appServer?: { server: string; surface?: "sky" | "browser"; modulePath?: string };
4545
slug?: string;
4646
};
4747
return {

packages/plugins/mcp/src/sdk/appserver-connector.test.ts

Lines changed: 97 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,15 @@ import { createMcpConnector, type StdioConnectorInput } from "./connection";
1515

1616
const fixture = fileURLToPath(new URL("./appserver-test-server.ts", import.meta.url));
1717

18-
const appServerInput = (server: string, surface?: "sky"): StdioConnectorInput => ({
18+
const appServerInput = (
19+
server: string,
20+
appServer?: { readonly surface?: "sky" | "browser"; readonly modulePath?: string },
21+
): StdioConnectorInput => ({
1922
transport: "stdio",
2023
command: "bun",
2124
args: ["run", fixture],
2225
env: { CODEX_HOME: "/tmp/fixture-codex-home" },
23-
appServer: { server, ...(surface === undefined ? {} : { surface }) },
26+
appServer: { server, ...appServer },
2427
});
2528

2629
const withConnection = (input: StdioConnectorInput) =>
@@ -126,7 +129,7 @@ describe("codex app-server bridge", () => {
126129
it.effect("the sky surface lists typed Computer Use tools, not the raw REPL", () =>
127130
Effect.scoped(
128131
Effect.gen(function* () {
129-
const connection = yield* withConnection(appServerInput("node_repl", "sky"));
132+
const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));
130133

131134
const tools = yield* Effect.promise(() => connection.client.listTools());
132135
const names = tools.tools.map(({ name }) => name);
@@ -144,7 +147,7 @@ describe("codex app-server bridge", () => {
144147
it.effect("a sky tool call compiles to one node_repl program carrying its arguments", () =>
145148
Effect.scoped(
146149
Effect.gen(function* () {
147-
const connection = yield* withConnection(appServerInput("node_repl", "sky"));
150+
const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));
148151

149152
// Quotes in the arguments matter: they are embedded into a JS source
150153
// text, so the encoding has to survive them exactly.
@@ -161,7 +164,10 @@ describe("codex app-server bridge", () => {
161164
`await sky.type_text(${JSON.stringify(args)})`,
162165
);
163166
expect(program, "returns the result as JSON through the REPL").toContain(
164-
"nodeRepl.write(JSON.stringify(__result ?? null));",
167+
"nodeRepl.write(JSON.stringify(result ?? null));",
168+
);
169+
expect(program, "runs in its own scope so a reused REPL session stays clean").toContain(
170+
"await (async () => {",
165171
);
166172
}),
167173
),
@@ -170,7 +176,7 @@ describe("codex app-server bridge", () => {
170176
it.effect("an argument-less sky tool calls its method with no argument object", () =>
171177
Effect.scoped(
172178
Effect.gen(function* () {
173-
const connection = yield* withConnection(appServerInput("node_repl", "sky"));
179+
const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));
174180

175181
const result = yield* Effect.promise(() =>
176182
connection.client.callTool({ name: "list_apps", arguments: {} }),
@@ -184,7 +190,7 @@ describe("codex app-server bridge", () => {
184190
it.effect("a tool outside the sky surface is refused rather than sent to the REPL", () =>
185191
Effect.scoped(
186192
Effect.gen(function* () {
187-
const connection = yield* withConnection(appServerInput("node_repl", "sky"));
193+
const connection = yield* withConnection(appServerInput("node_repl", { surface: "sky" }));
188194

189195
const outcome = yield* Effect.promise(() =>
190196
connection.client.callTool({ name: "js", arguments: { code: "process.exit(0)" } }).then(
@@ -197,6 +203,90 @@ describe("codex app-server bridge", () => {
197203
),
198204
);
199205

206+
// -------------------------------------------------------------------------
207+
// Chrome: also projected onto `node_repl`, but handle-based.
208+
// -------------------------------------------------------------------------
209+
210+
const BROWSER_MODULE = "/codex/chrome/latest/scripts/browser-client.mjs";
211+
const browserInput = () =>
212+
appServerInput("node_repl", { surface: "browser", modulePath: BROWSER_MODULE });
213+
214+
it.effect("the browser surface lists typed Chrome tools, not the raw REPL", () =>
215+
Effect.scoped(
216+
Effect.gen(function* () {
217+
const connection = yield* withConnection(browserInput());
218+
219+
const names = yield* Effect.promise(() =>
220+
connection.client.listTools().then((result) => result.tools.map(({ name }) => name)),
221+
);
222+
expect(names, "the raw REPL is not exposed").not.toContain("js");
223+
expect(names).toEqual(
224+
expect.arrayContaining(["list_tabs", "new_tab", "navigate", "read_page", "click"]),
225+
);
226+
}),
227+
),
228+
);
229+
230+
it.effect("a browser call imports the machine's own client and resolves a tab", () =>
231+
Effect.scoped(
232+
Effect.gen(function* () {
233+
const connection = yield* withConnection(browserInput());
234+
235+
const result = yield* Effect.promise(() =>
236+
connection.client.callTool({
237+
name: "navigate",
238+
arguments: { url: "https://example.com/" },
239+
}),
240+
);
241+
const program = (result.content as readonly { readonly text: string }[])[0]!.text;
242+
expect(program, "imports the scanner-resolved client path").toContain(
243+
`await import(${JSON.stringify(BROWSER_MODULE)})`,
244+
);
245+
expect(program, "caches the runtime across calls in the pooled session").toContain(
246+
"globalThis.__executorBrowser ??=",
247+
);
248+
expect(program, "falls back to the selected tab, opening one if needed").toContain(
249+
"(await __browser.tabs.selected()) ?? (await __browser.tabs.new())",
250+
);
251+
expect(program).toContain("await __tab.goto(__args.url)");
252+
}),
253+
),
254+
);
255+
256+
it.effect("stamps REPL calls with the turn metadata the Chrome client requires", () =>
257+
Effect.scoped(
258+
Effect.gen(function* () {
259+
// Without this the real client refuses every call with "Missing
260+
// required Codex turn metadata": Codex normally stamps a REPL call
261+
// with its issuing turn, and this bridge runs no turns.
262+
const connection = yield* withConnection(browserInput());
263+
264+
const result = yield* Effect.promise(() =>
265+
connection.client.callTool({ name: "list_tabs", arguments: {} }),
266+
);
267+
const meta = (result.structuredContent as { readonly meta: Record<string, unknown> }).meta;
268+
const turn = meta["x-codex-turn-metadata"] as Record<string, string>;
269+
expect(typeof turn.session_id, "the pooled thread is the session").toBe("string");
270+
expect(typeof turn.turn_id, "each call is its own turn").toBe("string");
271+
}),
272+
),
273+
);
274+
275+
it.effect("a tab-less browser tool skips tab resolution entirely", () =>
276+
Effect.scoped(
277+
Effect.gen(function* () {
278+
const connection = yield* withConnection(browserInput());
279+
280+
const result = yield* Effect.promise(() =>
281+
connection.client.callTool({ name: "list_tabs", arguments: {} }),
282+
);
283+
const program = (result.content as readonly { readonly text: string }[])[0]!.text;
284+
expect(program).toContain("await __browser.tabs.list()");
285+
expect(program, "no tab is resolved for a browser-level call").not.toContain("const __tab");
286+
}),
287+
),
288+
);
289+
200290
it.effect("a server name Codex does not report fails the tools listing, not the connect", () =>
201291
Effect.scoped(
202292
Effect.gen(function* () {

packages/plugins/mcp/src/sdk/appserver-connector.ts

Lines changed: 65 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
import type { JSONRPCMessage, JSONRPCRequest, Transport } from "@modelcontextprotocol/client";
4040
import { Option, Schema } from "effect";
4141

42+
import { browserCallProgram, browserToolList, findBrowserTool } from "./codex-browser-tools";
4243
import { findSkyTool, skyCallProgram, skyToolList } from "./codex-sky-tools";
4344
import { stdioSpawnEnv, type StdioTransportConfig } from "./stdio-connector";
4445

@@ -78,10 +79,14 @@ export type AppServerTransportConfig = StdioTransportConfig & {
7879
/** The MCP server name inside Codex whose tools this transport exposes
7980
* (e.g. `messages`) — the `server` of every `mcpServer/tool/call`. */
8081
readonly server: string;
81-
/** `sky` projects the Codex Computer Use API over the `node_repl` server as
82-
* typed tools instead of exposing the REPL itself (see
83-
* `codex-sky-tools.ts`). Absent exposes the server's tools verbatim. */
84-
readonly surface?: "sky";
82+
/** A projected tool surface for a plugin driven through `node_repl` rather
83+
* than serving MCP itself: `sky` is Computer Use (`codex-sky-tools.ts`),
84+
* `browser` is Chrome (`codex-browser-tools.ts`). Absent exposes the
85+
* server's own tools verbatim. */
86+
readonly surface?: "sky" | "browser";
87+
/** Absolute path to the module a projected surface imports (Chrome's
88+
* `browser-client.mjs`); resolved per machine by the scanner. */
89+
readonly modulePath?: string;
8590
};
8691

8792
// ---------------------------------------------------------------------------
@@ -173,6 +178,9 @@ type AppServerReply =
173178
const INTERNAL_ERROR = -32603;
174179
const METHOD_NOT_FOUND = -32601;
175180

181+
/** Ceiling for one browser action inside the REPL. */
182+
const BROWSER_TIMEOUT_MS = 120_000;
183+
176184
const CHANNEL_CLOSED: AppServerReply = {
177185
ok: false,
178186
error: { code: INTERNAL_ERROR, message: "Codex app-server exited before replying" },
@@ -370,6 +378,10 @@ class AppServerClientTransport implements Transport {
370378
this.#emit({ jsonrpc: "2.0", id: message.id, result: { tools: skyToolList() } });
371379
return;
372380
}
381+
if (this.#config.surface === "browser") {
382+
this.#emit({ jsonrpc: "2.0", id: message.id, result: { tools: browserToolList() } });
383+
return;
384+
}
373385
const tools = await this.#collectServerTools(message.id);
374386
if (tools === undefined) return;
375387
this.#emit({ jsonrpc: "2.0", id: message.id, result: { tools } });
@@ -426,7 +438,7 @@ class AppServerClientTransport implements Transport {
426438
if (call === undefined) {
427439
this.#fail(message.id, {
428440
code: METHOD_NOT_FOUND,
429-
message: `Unknown Computer Use tool "${params.value.name}"`,
441+
message: `Unknown tool "${params.value.name}" for this Codex plugin`,
430442
});
431443
return;
432444
}
@@ -461,27 +473,70 @@ class AppServerClientTransport implements Transport {
461473
* performs it; otherwise the tool is passed through by name. Undefined
462474
* means the surface does not define that tool. */
463475
#toolCallParams(name: string, args: unknown): Record<string, unknown> | undefined {
464-
if (this.#config.surface !== "sky") {
476+
const program = this.#surfaceProgram(name, args);
477+
if (program === "unknown-tool") return undefined;
478+
if (program === undefined) {
465479
return {
466480
threadId: this.#threadId,
467481
server: this.#config.server,
468482
tool: name,
469483
arguments: args ?? {},
470484
};
471485
}
472-
const tool = findSkyTool(name);
473-
if (tool === undefined) return undefined;
474486
return {
475487
threadId: this.#threadId,
476488
server: this.#config.server,
477489
tool: "js",
478490
arguments: {
479-
code: skyCallProgram(tool, args),
480-
title: `Computer Use: ${tool.name}`,
491+
code: program.code,
492+
title: program.title,
493+
...(program.timeoutMs === undefined ? {} : { timeout_ms: program.timeoutMs }),
494+
},
495+
// Codex normally stamps a REPL call with the turn that issued it, and
496+
// the Chrome client REFUSES to run without it ("Missing required Codex
497+
// turn metadata"). This bridge starts no turns, so it supplies the same
498+
// shape: the pooled thread is the session, and each tool call is its own
499+
// turn. Computer Use does not check for it, but it is node_repl-backed
500+
// too and Codex would stamp it, so both surfaces send it.
501+
_meta: {
502+
"x-codex-turn-metadata": {
503+
session_id: this.#threadId,
504+
turn_id: crypto.randomUUID(),
505+
},
481506
},
482507
};
483508
}
484509

510+
/** The REPL program for a projected surface: `undefined` when this
511+
* connection exposes the server's own tools, `"unknown-tool"` when the
512+
* surface does not define `name`. */
513+
#surfaceProgram(
514+
name: string,
515+
args: unknown,
516+
):
517+
| { readonly code: string; readonly title: string; readonly timeoutMs?: number }
518+
| undefined
519+
| "unknown-tool" {
520+
if (this.#config.surface === "sky") {
521+
const tool = findSkyTool(name);
522+
if (tool === undefined) return "unknown-tool";
523+
return { code: skyCallProgram(tool, args), title: `Computer Use: ${tool.name}` };
524+
}
525+
if (this.#config.surface === "browser") {
526+
const tool = findBrowserTool(name);
527+
if (tool === undefined) return "unknown-tool";
528+
return {
529+
code: browserCallProgram(tool, args, this.#config.modulePath ?? ""),
530+
title: `Chrome: ${tool.name}`,
531+
// The REPL's own 30s default is too short for real navigation: a
532+
// page load plus its accessibility pass routinely outruns it, and the
533+
// failure surfaces as an opaque REPL timeout rather than a page error.
534+
timeoutMs: BROWSER_TIMEOUT_MS,
535+
};
536+
}
537+
return undefined;
538+
}
539+
485540
// -------------------------------------------------------------------------
486541
// Downstream traffic
487542
// -------------------------------------------------------------------------

0 commit comments

Comments
 (0)