codex-control-chrome-mcp 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,299 @@
1
+ import crypto from "node:crypto";
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { z } from "zod";
5
+ import { connectBridge, listBridgeSockets } from "./bridge-client.js";
6
+ import { nativeHostManifestPath } from "./constants.js";
7
+
8
+ export async function runMcpServer() {
9
+ const runtime = new ChromeUseRuntime();
10
+ const server = new McpServer({
11
+ name: "codex-control-chrome-mcp",
12
+ version: "1.0.0",
13
+ });
14
+
15
+ registerTools(server, runtime);
16
+
17
+ const transport = new StdioServerTransport();
18
+ await server.connect(transport);
19
+ }
20
+
21
+ class ChromeUseRuntime {
22
+ constructor() {
23
+ this.sessionId = `codex-control-chrome-mcp-${crypto.randomUUID()}`;
24
+ this.bridge = null;
25
+ }
26
+
27
+ async rpc() {
28
+ if (!this.bridge) {
29
+ this.bridge = await connectBridge();
30
+ }
31
+ return this.bridge.rpc;
32
+ }
33
+
34
+ sessionParams() {
35
+ return {
36
+ session_id: this.sessionId,
37
+ turn_id: `turn-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
38
+ session_context: "codex-control-chrome-mcp",
39
+ };
40
+ }
41
+
42
+ async request(method, params = {}, options = {}) {
43
+ const rpc = await this.rpc();
44
+ return await rpc.request(method, { ...params, ...this.sessionParams() }, options);
45
+ }
46
+
47
+ async rawRequest(method, params = {}, options = {}) {
48
+ const rpc = await this.rpc();
49
+ return await rpc.request(method, params, options);
50
+ }
51
+ }
52
+
53
+ function registerTools(server, runtime) {
54
+ server.registerTool(
55
+ "chrome_status",
56
+ {
57
+ description: "Show native host manifest path and discovered bridge sockets.",
58
+ inputSchema: {},
59
+ },
60
+ async () => toolResult({
61
+ manifestPath: nativeHostManifestPath(),
62
+ sockets: await listBridgeSockets(),
63
+ }),
64
+ );
65
+
66
+ server.registerTool(
67
+ "chrome_bridge_status",
68
+ {
69
+ description: "Ask the active Codex Control Chrome MCP bridge for status.",
70
+ inputSchema: {},
71
+ },
72
+ async () => toolResult(await runtime.rawRequest("bridge.getStatus", {})),
73
+ );
74
+
75
+ server.registerTool(
76
+ "chrome_get_info",
77
+ {
78
+ description: "Get connected Codex Chrome extension backend info.",
79
+ inputSchema: {},
80
+ },
81
+ async () => toolResult(await runtime.request("getInfo")),
82
+ );
83
+
84
+ server.registerTool(
85
+ "chrome_name_session",
86
+ {
87
+ description: "Set the Chrome tab group/session name.",
88
+ inputSchema: {
89
+ name: z.string().default("Codex Control Chrome MCP"),
90
+ },
91
+ },
92
+ async ({ name }) => {
93
+ await runtime.request("nameSession", { name });
94
+ return toolResult({ ok: true });
95
+ },
96
+ );
97
+
98
+ server.registerTool(
99
+ "chrome_user_tabs",
100
+ {
101
+ description: "List user Chrome tabs visible to the extension.",
102
+ inputSchema: {},
103
+ },
104
+ async () => toolResult({ tabs: await runtime.request("getUserTabs") }),
105
+ );
106
+
107
+ server.registerTool(
108
+ "chrome_session_tabs",
109
+ {
110
+ description: "List tabs currently claimed by this MCP browser session.",
111
+ inputSchema: {},
112
+ },
113
+ async () => toolResult({ tabs: await runtime.request("getTabs") }),
114
+ );
115
+
116
+ server.registerTool(
117
+ "chrome_create_tab",
118
+ {
119
+ description: "Create a new background tab and claim it for this MCP session.",
120
+ inputSchema: {},
121
+ },
122
+ async () => toolResult(await runtime.request("createTab")),
123
+ );
124
+
125
+ server.registerTool(
126
+ "chrome_claim_tab",
127
+ {
128
+ description: "Claim an existing Chrome tab by numeric tab id.",
129
+ inputSchema: {
130
+ tabId: z.number().int().nonnegative(),
131
+ },
132
+ },
133
+ async ({ tabId }) => toolResult(await runtime.request("claimUserTab", { tabId })),
134
+ );
135
+
136
+ server.registerTool(
137
+ "chrome_attach_tab",
138
+ {
139
+ description: "Attach Chrome debugger to a claimed tab.",
140
+ inputSchema: {
141
+ tabId: z.number().int().nonnegative(),
142
+ },
143
+ },
144
+ async ({ tabId }) => {
145
+ await runtime.request("attach", { tabId });
146
+ return toolResult({ ok: true });
147
+ },
148
+ );
149
+
150
+ server.registerTool(
151
+ "chrome_detach_tab",
152
+ {
153
+ description: "Detach Chrome debugger from a claimed tab.",
154
+ inputSchema: {
155
+ tabId: z.number().int().nonnegative(),
156
+ },
157
+ },
158
+ async ({ tabId }) => {
159
+ await runtime.request("detach", { tabId });
160
+ return toolResult({ ok: true });
161
+ },
162
+ );
163
+
164
+ server.registerTool(
165
+ "chrome_cdp_send",
166
+ {
167
+ description: "Send a raw CDP command to a claimed and attached tab.",
168
+ inputSchema: {
169
+ tabId: z.number().int().nonnegative(),
170
+ method: z.string().min(1),
171
+ params: z.record(z.string(), z.unknown()).default({}),
172
+ timeoutMs: z.number().int().positive().optional(),
173
+ },
174
+ },
175
+ async (args) => toolResult(await executeCdp(runtime, args)),
176
+ );
177
+
178
+ server.registerTool(
179
+ "chrome_navigate",
180
+ {
181
+ description: "Navigate a claimed tab to a URL using CDP Page.navigate.",
182
+ inputSchema: {
183
+ tabId: z.number().int().nonnegative(),
184
+ url: z.string().url(),
185
+ },
186
+ },
187
+ async ({ tabId, url }) => {
188
+ await runtime.request("attach", { tabId });
189
+ await executeCdp(runtime, { tabId, method: "Page.enable", params: {} });
190
+ return toolResult(await executeCdp(runtime, {
191
+ tabId,
192
+ method: "Page.navigate",
193
+ params: { url },
194
+ }));
195
+ },
196
+ );
197
+
198
+ server.registerTool(
199
+ "chrome_evaluate",
200
+ {
201
+ description: "Evaluate JavaScript in a claimed tab using Runtime.evaluate.",
202
+ inputSchema: {
203
+ tabId: z.number().int().nonnegative(),
204
+ expression: z.string().min(1),
205
+ awaitPromise: z.boolean().default(true),
206
+ },
207
+ },
208
+ async ({ tabId, expression, awaitPromise }) => {
209
+ await runtime.request("attach", { tabId });
210
+ await executeCdp(runtime, { tabId, method: "Runtime.enable", params: {} });
211
+ return toolResult(await executeCdp(runtime, {
212
+ tabId,
213
+ method: "Runtime.evaluate",
214
+ params: {
215
+ expression,
216
+ awaitPromise,
217
+ returnByValue: true,
218
+ },
219
+ }));
220
+ },
221
+ );
222
+
223
+ server.registerTool(
224
+ "chrome_screenshot",
225
+ {
226
+ description: "Capture a screenshot from a claimed tab. Returns base64 PNG.",
227
+ inputSchema: {
228
+ tabId: z.number().int().nonnegative(),
229
+ format: z.enum(["png", "jpeg", "webp"]).default("png"),
230
+ },
231
+ },
232
+ async ({ tabId, format }) => {
233
+ await runtime.request("attach", { tabId });
234
+ await executeCdp(runtime, { tabId, method: "Page.enable", params: {} });
235
+ return toolResult(await executeCdp(runtime, {
236
+ tabId,
237
+ method: "Page.captureScreenshot",
238
+ params: { format, captureBeyondViewport: true },
239
+ timeoutMs: 30_000,
240
+ }));
241
+ },
242
+ );
243
+
244
+ server.registerTool(
245
+ "chrome_read_events",
246
+ {
247
+ description: "Read and clear CDP/download notifications buffered by this MCP process.",
248
+ inputSchema: {
249
+ method: z.string().optional(),
250
+ limit: z.number().int().positive().default(100),
251
+ },
252
+ },
253
+ async ({ method, limit }) => {
254
+ const rpc = await runtime.rpc();
255
+ return toolResult({ events: rpc.readNotifications({ method, limit }) });
256
+ },
257
+ );
258
+
259
+ server.registerTool(
260
+ "chrome_finalize_tabs",
261
+ {
262
+ description: "Finalize claimed tabs. Use status handoff or deliverable.",
263
+ inputSchema: {
264
+ keep: z.array(z.object({
265
+ tabId: z.number().int().nonnegative(),
266
+ status: z.enum(["handoff", "deliverable"]),
267
+ })).default([]),
268
+ },
269
+ },
270
+ async ({ keep }) => {
271
+ await runtime.request("finalizeTabs", { keep });
272
+ return toolResult({ ok: true });
273
+ },
274
+ );
275
+ }
276
+
277
+ async function executeCdp(runtime, { tabId, method, params = {}, timeoutMs }) {
278
+ return await runtime.request(
279
+ "executeCdp",
280
+ {
281
+ target: { tabId },
282
+ method,
283
+ commandParams: params,
284
+ ...(timeoutMs == null ? {} : { timeoutMs }),
285
+ },
286
+ { timeoutMs: Math.max(timeoutMs ?? 30_000, 30_000) },
287
+ );
288
+ }
289
+
290
+ function toolResult(value) {
291
+ return {
292
+ content: [
293
+ {
294
+ type: "text",
295
+ text: typeof value === "string" ? value : JSON.stringify(value, null, 2),
296
+ },
297
+ ],
298
+ };
299
+ }
@@ -0,0 +1,235 @@
1
+ import crypto from "node:crypto";
2
+ import fs from "node:fs/promises";
3
+ import net from "node:net";
4
+ import path from "node:path";
5
+ import { spawn } from "node:child_process";
6
+ import { APP_CONFIG_PATH, BRIDGE_SOCKET_DIR, BRIDGE_SOCKET_PREFIX } from "./constants.js";
7
+ import { LengthPrefixedJsonPeer } from "./framing.js";
8
+
9
+ const LOCAL_ID_PREFIX = "codex-control-chrome-mcp:";
10
+
11
+ export async function runNativeHost({ origin = process.argv[2] } = {}) {
12
+ await fs.mkdir(BRIDGE_SOCKET_DIR, { recursive: true });
13
+ const socketPath = path.join(
14
+ BRIDGE_SOCKET_DIR,
15
+ `${BRIDGE_SOCKET_PREFIX}-${process.pid}-${crypto.randomUUID()}.sock`,
16
+ );
17
+
18
+ const extensionPeer = new LengthPrefixedJsonPeer({
19
+ input: process.stdin,
20
+ output: process.stdout,
21
+ name: "chrome-extension",
22
+ });
23
+
24
+ const proxyPeer = await maybeStartOfficialProxy(origin);
25
+ const bridge = new NativeHostBridge({ extensionPeer, proxyPeer });
26
+ await bridge.listen(socketPath);
27
+
28
+ const cleanup = async () => {
29
+ await bridge.close();
30
+ try {
31
+ await fs.unlink(socketPath);
32
+ } catch {}
33
+ };
34
+ process.once("SIGINT", () => cleanup().finally(() => process.exit(130)));
35
+ process.once("SIGTERM", () => cleanup().finally(() => process.exit(143)));
36
+ process.once("exit", () => {
37
+ try {
38
+ bridge.closeSync();
39
+ } catch {}
40
+ });
41
+ }
42
+
43
+ class NativeHostBridge {
44
+ constructor({ extensionPeer, proxyPeer = null }) {
45
+ this.extensionPeer = extensionPeer;
46
+ this.proxyPeer = proxyPeer;
47
+ this.server = null;
48
+ this.clients = new Map();
49
+ this.nextClientId = 1;
50
+ this.nextRequestId = 1;
51
+ this.pendingLocalRequests = new Map();
52
+
53
+ this.extensionPeer.on("message", (message) => this.#handleExtensionMessage(message));
54
+ this.extensionPeer.on("close", () => this.close());
55
+
56
+ if (this.proxyPeer) {
57
+ this.proxyPeer.on("message", (message) => this.#handleProxyMessage(message));
58
+ this.proxyPeer.on("close", () => {
59
+ this.proxyPeer = null;
60
+ });
61
+ }
62
+ }
63
+
64
+ async listen(socketPath) {
65
+ this.socketPath = socketPath;
66
+ this.server = net.createServer((socket) => this.#addClient(socket));
67
+ await new Promise((resolve, reject) => {
68
+ this.server.once("error", reject);
69
+ this.server.listen(socketPath, () => {
70
+ this.server.off("error", reject);
71
+ resolve();
72
+ });
73
+ });
74
+ }
75
+
76
+ async close() {
77
+ for (const peer of this.clients.values()) {
78
+ peer.close();
79
+ }
80
+ this.clients.clear();
81
+ if (this.server) {
82
+ await new Promise((resolve) => this.server.close(resolve));
83
+ this.server = null;
84
+ }
85
+ }
86
+
87
+ closeSync() {
88
+ for (const peer of this.clients.values()) {
89
+ peer.close();
90
+ }
91
+ this.server?.close();
92
+ }
93
+
94
+ #addClient(socket) {
95
+ const clientId = this.nextClientId++;
96
+ const peer = new LengthPrefixedJsonPeer({
97
+ input: socket,
98
+ output: socket,
99
+ name: `local-client:${clientId}`,
100
+ });
101
+ this.clients.set(clientId, peer);
102
+ peer.on("message", (message) => this.#handleLocalMessage(clientId, peer, message));
103
+ peer.on("close", () => {
104
+ this.clients.delete(clientId);
105
+ for (const [bridgeId, pending] of this.pendingLocalRequests) {
106
+ if (pending.clientId === clientId) {
107
+ this.pendingLocalRequests.delete(bridgeId);
108
+ }
109
+ }
110
+ });
111
+ }
112
+
113
+ #handleLocalMessage(clientId, peer, message) {
114
+ if (!message || typeof message !== "object" || !("method" in message)) {
115
+ return;
116
+ }
117
+ if (!("id" in message)) {
118
+ this.extensionPeer.send(message);
119
+ return;
120
+ }
121
+ if (message.method === "bridge.getStatus") {
122
+ peer.send({
123
+ jsonrpc: "2.0",
124
+ id: message.id,
125
+ result: {
126
+ ok: true,
127
+ proxy: Boolean(this.proxyPeer),
128
+ clients: this.clients.size,
129
+ socketPath: this.socketPath,
130
+ },
131
+ });
132
+ return;
133
+ }
134
+ const bridgeId = `${LOCAL_ID_PREFIX}${clientId}:${this.nextRequestId++}`;
135
+ this.pendingLocalRequests.set(bridgeId, { clientId, originalId: message.id, peer });
136
+ this.extensionPeer.send({ ...message, id: bridgeId });
137
+ }
138
+
139
+ #handleExtensionMessage(message) {
140
+ if (!message || typeof message !== "object") {
141
+ return;
142
+ }
143
+ if ("id" in message && !("method" in message)) {
144
+ const id = String(message.id);
145
+ const pending = this.pendingLocalRequests.get(id);
146
+ if (pending) {
147
+ this.pendingLocalRequests.delete(id);
148
+ pending.peer.send({ ...message, id: pending.originalId });
149
+ } else {
150
+ this.proxyPeer?.send(message);
151
+ }
152
+ return;
153
+ }
154
+ if ("method" in message && !("id" in message)) {
155
+ this.#broadcast(message);
156
+ this.proxyPeer?.send(message);
157
+ return;
158
+ }
159
+ if ("method" in message && "id" in message) {
160
+ if (this.proxyPeer) {
161
+ this.proxyPeer.send(message);
162
+ } else {
163
+ this.#handleExtensionRequestStandalone(message);
164
+ }
165
+ }
166
+ }
167
+
168
+ #handleProxyMessage(message) {
169
+ this.extensionPeer.send(message);
170
+ }
171
+
172
+ #handleExtensionRequestStandalone(message) {
173
+ if (message.method === "ping") {
174
+ this.extensionPeer.send({ jsonrpc: "2.0", id: message.id, result: "pong" });
175
+ return;
176
+ }
177
+ if (message.method === "ensureCodexAppServer") {
178
+ this.extensionPeer.send({
179
+ jsonrpc: "2.0",
180
+ id: message.id,
181
+ result: {
182
+ ok: false,
183
+ error: "Codex app server is not provided by codex-control-chrome-mcp standalone mode.",
184
+ },
185
+ });
186
+ return;
187
+ }
188
+ this.extensionPeer.send({
189
+ jsonrpc: "2.0",
190
+ id: message.id,
191
+ error: { code: -32601, message: `No native host handler for ${message.method}` },
192
+ });
193
+ }
194
+
195
+ #broadcast(message) {
196
+ for (const peer of this.clients.values()) {
197
+ try {
198
+ peer.send(message);
199
+ } catch {}
200
+ }
201
+ }
202
+ }
203
+
204
+ async function maybeStartOfficialProxy(origin) {
205
+ const config = await readJsonIfExists(APP_CONFIG_PATH);
206
+ const officialHostPath = config?.officialHostPath;
207
+ if (typeof officialHostPath !== "string" || officialHostPath.length === 0) {
208
+ return null;
209
+ }
210
+ if (path.resolve(officialHostPath) === path.resolve(process.argv[1])) {
211
+ return null;
212
+ }
213
+ try {
214
+ await fs.access(officialHostPath);
215
+ } catch {
216
+ return null;
217
+ }
218
+
219
+ const child = spawn(officialHostPath, [origin ?? ""], {
220
+ stdio: ["pipe", "pipe", "ignore"],
221
+ });
222
+ return new LengthPrefixedJsonPeer({
223
+ input: child.stdout,
224
+ output: child.stdin,
225
+ name: "official-extension-host",
226
+ });
227
+ }
228
+
229
+ async function readJsonIfExists(filePath) {
230
+ try {
231
+ return JSON.parse(await fs.readFile(filePath, "utf8"));
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
package/src/probe.js ADDED
@@ -0,0 +1,39 @@
1
+ import net from "node:net";
2
+ import { encodeLengthPrefixedJson } from "./framing.js";
3
+
4
+ export async function probeLengthPrefixedSocket(socketPath) {
5
+ const events = [];
6
+ await new Promise((resolve) => {
7
+ const socket = net.createConnection(socketPath);
8
+ const timer = setTimeout(() => {
9
+ events.push({ type: "timeout" });
10
+ socket.destroy();
11
+ resolve();
12
+ }, 3000);
13
+
14
+ socket.on("connect", () => {
15
+ events.push({ type: "connect" });
16
+ socket.write(
17
+ encodeLengthPrefixedJson({
18
+ jsonrpc: "2.0",
19
+ id: 1,
20
+ method: "getInfo",
21
+ params: {
22
+ session_id: "probe-session",
23
+ turn_id: "probe-turn",
24
+ session_context: "probe",
25
+ },
26
+ }),
27
+ );
28
+ });
29
+ socket.on("data", (chunk) => events.push({ type: "data", bytes: chunk.length }));
30
+ socket.on("end", () => events.push({ type: "end" }));
31
+ socket.on("error", (error) => events.push({ type: "error", message: error.message }));
32
+ socket.on("close", () => {
33
+ clearTimeout(timer);
34
+ events.push({ type: "close" });
35
+ resolve();
36
+ });
37
+ });
38
+ return events;
39
+ }
@@ -0,0 +1,77 @@
1
+ export class RpcClient {
2
+ constructor(peer, { idPrefix = "req" } = {}) {
3
+ this.peer = peer;
4
+ this.idPrefix = idPrefix;
5
+ this.nextId = 1;
6
+ this.pending = new Map();
7
+ this.notifications = [];
8
+
9
+ this.peer.on("message", (message) => this.#handleMessage(message));
10
+ this.peer.on("close", (error) => this.#rejectAll(error ?? new Error("transport closed")));
11
+ }
12
+
13
+ request(method, params = {}, { timeoutMs = 30_000 } = {}) {
14
+ const id = `${this.idPrefix}:${this.nextId++}`;
15
+ const message = { jsonrpc: "2.0", id, method, params };
16
+ return new Promise((resolve, reject) => {
17
+ const timeout = setTimeout(() => {
18
+ this.pending.delete(id);
19
+ reject(new Error(`Timed out waiting for ${method}`));
20
+ }, timeoutMs);
21
+ this.pending.set(id, { resolve, reject, timeout });
22
+ try {
23
+ this.peer.send(message);
24
+ } catch (error) {
25
+ clearTimeout(timeout);
26
+ this.pending.delete(id);
27
+ reject(error);
28
+ }
29
+ });
30
+ }
31
+
32
+ readNotifications({ method, limit = 100 } = {}) {
33
+ const selected = [];
34
+ const remaining = [];
35
+ for (const item of this.notifications) {
36
+ if (selected.length < limit && (method == null || item.method === method)) {
37
+ selected.push(item);
38
+ } else {
39
+ remaining.push(item);
40
+ }
41
+ }
42
+ this.notifications = remaining;
43
+ return selected;
44
+ }
45
+
46
+ #handleMessage(message) {
47
+ if (message && typeof message === "object" && "id" in message && !("method" in message)) {
48
+ const pending = this.pending.get(message.id);
49
+ if (!pending) {
50
+ return;
51
+ }
52
+ this.pending.delete(message.id);
53
+ clearTimeout(pending.timeout);
54
+ if ("error" in message) {
55
+ pending.reject(new Error(message.error?.message ?? String(message.error)));
56
+ } else {
57
+ pending.resolve(message.result);
58
+ }
59
+ return;
60
+ }
61
+ if (message && typeof message === "object" && "method" in message && !("id" in message)) {
62
+ this.notifications.push({
63
+ method: message.method,
64
+ params: message.params,
65
+ receivedAt: new Date().toISOString(),
66
+ });
67
+ }
68
+ }
69
+
70
+ #rejectAll(error) {
71
+ for (const pending of this.pending.values()) {
72
+ clearTimeout(pending.timeout);
73
+ pending.reject(error);
74
+ }
75
+ this.pending.clear();
76
+ }
77
+ }