chrome-relay 0.1.1 → 0.2.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.
- package/README.md +39 -0
- package/dist/cli.js +122 -174
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/native-host.js +22 -245
- package/package.json +3 -5
- package/dist/stdio.d.ts +0 -4
- package/dist/stdio.js +0 -178
package/README.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# chrome-relay
|
|
2
|
+
|
|
3
|
+
`chrome-relay` connects your local Chrome browser to coding agents through a local bridge and a Chrome extension.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
pnpm add -g chrome-relay
|
|
9
|
+
chrome-relay install
|
|
10
|
+
chrome-relay doctor
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Then load the Browser Relay extension in Chrome.
|
|
14
|
+
|
|
15
|
+
## Usage
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
chrome-relay tabs
|
|
19
|
+
chrome-relay read -i
|
|
20
|
+
chrome-relay navigate "https://example.com" --new
|
|
21
|
+
chrome-relay click "<selector>"
|
|
22
|
+
chrome-relay fill "<selector>" "value"
|
|
23
|
+
chrome-relay keys "Enter"
|
|
24
|
+
chrome-relay screenshot -o page.png
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## How it works
|
|
28
|
+
|
|
29
|
+
`chrome-relay` is a CLI-first browser bridge:
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
chrome-relay CLI
|
|
33
|
+
-> local bridge on your machine
|
|
34
|
+
-> Chrome native host
|
|
35
|
+
-> Browser Relay extension
|
|
36
|
+
-> Chrome APIs
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
The CLI does not need separate MCP configuration. It talks to the local bridge for you.
|
package/dist/cli.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
+
import { writeFileSync } from "fs";
|
|
5
6
|
|
|
6
7
|
// src/index.ts
|
|
7
|
-
var CHROME_RELAY_VERSION = "0.
|
|
8
|
+
var CHROME_RELAY_VERSION = "0.2.0";
|
|
8
9
|
|
|
9
10
|
// src/install/install.ts
|
|
10
11
|
import os from "os";
|
|
@@ -16,144 +17,6 @@ import { fileURLToPath } from "url";
|
|
|
16
17
|
var NATIVE_HOST_NAME = "dev.chrome_relay.native_host";
|
|
17
18
|
var DEFAULT_HTTP_PORT = 12122;
|
|
18
19
|
var DEFAULT_EXTENSION_ID = "cdmmkpadhnpcfjljhgpdnnljhjafmhop";
|
|
19
|
-
var TOOL_NAMES = {
|
|
20
|
-
GET_WINDOWS_AND_TABS: "get_windows_and_tabs",
|
|
21
|
-
NAVIGATE: "chrome_navigate",
|
|
22
|
-
SWITCH_TAB: "chrome_switch_tab",
|
|
23
|
-
CLOSE_TABS: "chrome_close_tabs",
|
|
24
|
-
SCREENSHOT: "chrome_screenshot",
|
|
25
|
-
READ_PAGE: "chrome_read_page",
|
|
26
|
-
CLICK: "chrome_click_element",
|
|
27
|
-
FILL: "chrome_fill_or_select",
|
|
28
|
-
KEYBOARD: "chrome_keyboard",
|
|
29
|
-
JAVASCRIPT: "chrome_javascript"
|
|
30
|
-
};
|
|
31
|
-
var TOOL_SCHEMAS = [
|
|
32
|
-
{
|
|
33
|
-
name: TOOL_NAMES.GET_WINDOWS_AND_TABS,
|
|
34
|
-
description: "List open Chrome windows and tabs.",
|
|
35
|
-
inputSchema: { type: "object", properties: {}, required: [] }
|
|
36
|
-
},
|
|
37
|
-
{
|
|
38
|
-
name: TOOL_NAMES.NAVIGATE,
|
|
39
|
-
description: "Navigate the current tab or a specific tab to a URL.",
|
|
40
|
-
inputSchema: {
|
|
41
|
-
type: "object",
|
|
42
|
-
properties: {
|
|
43
|
-
url: { type: "string", description: "Destination URL." },
|
|
44
|
-
tabId: { type: "number", description: "Optional target tab ID." },
|
|
45
|
-
newTab: { type: "boolean", description: "Open the URL in a new tab." },
|
|
46
|
-
active: { type: "boolean", description: "Whether the navigated tab should be active." }
|
|
47
|
-
},
|
|
48
|
-
required: ["url"]
|
|
49
|
-
}
|
|
50
|
-
},
|
|
51
|
-
{
|
|
52
|
-
name: TOOL_NAMES.SWITCH_TAB,
|
|
53
|
-
description: "Switch the active browser tab.",
|
|
54
|
-
inputSchema: {
|
|
55
|
-
type: "object",
|
|
56
|
-
properties: {
|
|
57
|
-
tabId: { type: "number", description: "Tab ID to activate." }
|
|
58
|
-
},
|
|
59
|
-
required: ["tabId"]
|
|
60
|
-
}
|
|
61
|
-
},
|
|
62
|
-
{
|
|
63
|
-
name: TOOL_NAMES.CLOSE_TABS,
|
|
64
|
-
description: "Close one or more tabs.",
|
|
65
|
-
inputSchema: {
|
|
66
|
-
type: "object",
|
|
67
|
-
properties: {
|
|
68
|
-
tabIds: {
|
|
69
|
-
type: "array",
|
|
70
|
-
description: "Tab IDs to close.",
|
|
71
|
-
items: { type: "number" }
|
|
72
|
-
}
|
|
73
|
-
},
|
|
74
|
-
required: ["tabIds"]
|
|
75
|
-
}
|
|
76
|
-
},
|
|
77
|
-
{
|
|
78
|
-
name: TOOL_NAMES.SCREENSHOT,
|
|
79
|
-
description: "Capture a screenshot of the current page.",
|
|
80
|
-
inputSchema: {
|
|
81
|
-
type: "object",
|
|
82
|
-
properties: {
|
|
83
|
-
tabId: { type: "number", description: "Optional target tab ID." },
|
|
84
|
-
fullPage: { type: "boolean", description: "Capture the full page when supported." }
|
|
85
|
-
},
|
|
86
|
-
required: []
|
|
87
|
-
}
|
|
88
|
-
},
|
|
89
|
-
{
|
|
90
|
-
name: TOOL_NAMES.READ_PAGE,
|
|
91
|
-
description: "Extract visible page structure and interactive elements.",
|
|
92
|
-
inputSchema: {
|
|
93
|
-
type: "object",
|
|
94
|
-
properties: {
|
|
95
|
-
tabId: { type: "number", description: "Optional target tab ID." },
|
|
96
|
-
interactiveOnly: {
|
|
97
|
-
type: "boolean",
|
|
98
|
-
description: "Return only interactive elements."
|
|
99
|
-
}
|
|
100
|
-
},
|
|
101
|
-
required: []
|
|
102
|
-
}
|
|
103
|
-
},
|
|
104
|
-
{
|
|
105
|
-
name: TOOL_NAMES.CLICK,
|
|
106
|
-
description: "Click a page element by selector.",
|
|
107
|
-
inputSchema: {
|
|
108
|
-
type: "object",
|
|
109
|
-
properties: {
|
|
110
|
-
selector: { type: "string", description: "CSS selector to click." },
|
|
111
|
-
tabId: { type: "number", description: "Optional target tab ID." }
|
|
112
|
-
},
|
|
113
|
-
required: ["selector"]
|
|
114
|
-
}
|
|
115
|
-
},
|
|
116
|
-
{
|
|
117
|
-
name: TOOL_NAMES.FILL,
|
|
118
|
-
description: "Fill an input or textarea by selector.",
|
|
119
|
-
inputSchema: {
|
|
120
|
-
type: "object",
|
|
121
|
-
properties: {
|
|
122
|
-
selector: { type: "string", description: "CSS selector to fill." },
|
|
123
|
-
value: { type: "string", description: "Text to insert." },
|
|
124
|
-
tabId: { type: "number", description: "Optional target tab ID." }
|
|
125
|
-
},
|
|
126
|
-
required: ["selector", "value"]
|
|
127
|
-
}
|
|
128
|
-
},
|
|
129
|
-
{
|
|
130
|
-
name: TOOL_NAMES.KEYBOARD,
|
|
131
|
-
description: "Send keyboard input to the active page.",
|
|
132
|
-
inputSchema: {
|
|
133
|
-
type: "object",
|
|
134
|
-
properties: {
|
|
135
|
-
keys: {
|
|
136
|
-
type: "string",
|
|
137
|
-
description: "Literal key text or chord, for example Enter or Meta+L."
|
|
138
|
-
},
|
|
139
|
-
tabId: { type: "number", description: "Optional target tab ID." }
|
|
140
|
-
},
|
|
141
|
-
required: ["keys"]
|
|
142
|
-
}
|
|
143
|
-
},
|
|
144
|
-
{
|
|
145
|
-
name: TOOL_NAMES.JAVASCRIPT,
|
|
146
|
-
description: "Evaluate JavaScript in the page context.",
|
|
147
|
-
inputSchema: {
|
|
148
|
-
type: "object",
|
|
149
|
-
properties: {
|
|
150
|
-
expression: { type: "string", description: "JavaScript expression to run." },
|
|
151
|
-
tabId: { type: "number", description: "Optional target tab ID." }
|
|
152
|
-
},
|
|
153
|
-
required: ["expression"]
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
];
|
|
157
20
|
|
|
158
21
|
// src/install/install.ts
|
|
159
22
|
var APP_DIR = path.join(os.homedir(), ".chrome-relay");
|
|
@@ -205,7 +68,7 @@ async function runInstall() {
|
|
|
205
68
|
console.log(`Installed Chrome Relay native host.`);
|
|
206
69
|
console.log(`Wrapper: ${wrapperPath}`);
|
|
207
70
|
console.log(`Manifest: ${manifestPath}`);
|
|
208
|
-
console.log(`
|
|
71
|
+
console.log(`Local bridge port: ${DEFAULT_HTTP_PORT}`);
|
|
209
72
|
}
|
|
210
73
|
async function runDoctor() {
|
|
211
74
|
try {
|
|
@@ -224,7 +87,7 @@ async function runDoctor() {
|
|
|
224
87
|
console.log(`Wrapper present: yes`);
|
|
225
88
|
console.log(`Manifest present: yes`);
|
|
226
89
|
console.log(`Allowed origin: ${manifest.allowed_origins?.[0] ?? "missing"}`);
|
|
227
|
-
console.log(`
|
|
90
|
+
console.log(`Local bridge reachable: ${serverReachable ? "yes" : "no"}`);
|
|
228
91
|
if (!serverReachable) {
|
|
229
92
|
console.log(`Tip: load the extension so it can launch the native host.`);
|
|
230
93
|
}
|
|
@@ -235,40 +98,31 @@ async function runDoctor() {
|
|
|
235
98
|
}
|
|
236
99
|
}
|
|
237
100
|
|
|
238
|
-
// src/
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
const
|
|
251
|
-
|
|
252
|
-
new
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
);
|
|
259
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_SCHEMAS }));
|
|
260
|
-
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
|
|
261
|
-
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
|
|
262
|
-
server.setRequestHandler(
|
|
263
|
-
CallToolRequestSchema,
|
|
264
|
-
async (request) => client.callTool({ name: request.params.name, arguments: request.params.arguments ?? {} })
|
|
265
|
-
);
|
|
266
|
-
await server.connect(new StdioServerTransport());
|
|
101
|
+
// src/client/call.ts
|
|
102
|
+
async function callTool(name, args) {
|
|
103
|
+
const response = await fetch(`http://127.0.0.1:${DEFAULT_HTTP_PORT}/call`, {
|
|
104
|
+
method: "POST",
|
|
105
|
+
headers: {
|
|
106
|
+
"content-type": "application/json"
|
|
107
|
+
},
|
|
108
|
+
body: JSON.stringify({
|
|
109
|
+
name,
|
|
110
|
+
args
|
|
111
|
+
})
|
|
112
|
+
});
|
|
113
|
+
const payload = await response.json().catch(() => null);
|
|
114
|
+
if (!response.ok) {
|
|
115
|
+
throw new Error(payload?.error || `Bridge request failed with ${response.status}`);
|
|
116
|
+
}
|
|
117
|
+
if (!payload?.ok) {
|
|
118
|
+
throw new Error(payload?.error || "Bridge call failed.");
|
|
119
|
+
}
|
|
120
|
+
return payload.data;
|
|
267
121
|
}
|
|
268
122
|
|
|
269
123
|
// src/cli.ts
|
|
270
124
|
var program = new Command();
|
|
271
|
-
program.name("chrome-relay").description("Connect your local Chrome browser to coding agents through
|
|
125
|
+
program.name("chrome-relay").description("Connect your local Chrome browser to coding agents through a local bridge.").version(CHROME_RELAY_VERSION);
|
|
272
126
|
program.command("install").description("Install and register the local Chrome Relay host.").action(async () => {
|
|
273
127
|
await runInstall();
|
|
274
128
|
});
|
|
@@ -276,7 +130,101 @@ program.command("doctor").description("Validate the local Chrome Relay installat
|
|
|
276
130
|
const ok = await runDoctor();
|
|
277
131
|
process.exit(ok ? 0 : 1);
|
|
278
132
|
});
|
|
279
|
-
|
|
280
|
-
|
|
133
|
+
async function run(name, args) {
|
|
134
|
+
try {
|
|
135
|
+
const result = await callTool(name, args);
|
|
136
|
+
if (typeof result === "string") {
|
|
137
|
+
process.stdout.write(result + "\n");
|
|
138
|
+
} else {
|
|
139
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
140
|
+
}
|
|
141
|
+
} catch (error) {
|
|
142
|
+
process.stderr.write(
|
|
143
|
+
(error instanceof Error ? error.message : String(error)) + "\n"
|
|
144
|
+
);
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function tabOpt(cmd) {
|
|
149
|
+
return cmd.option("-t, --tab <id>", "target tab ID", (v) => Number(v));
|
|
150
|
+
}
|
|
151
|
+
tabOpt(
|
|
152
|
+
program.command("tabs").description("List open Chrome windows and tabs.")
|
|
153
|
+
).action(async () => {
|
|
154
|
+
await run("get_windows_and_tabs", {});
|
|
155
|
+
});
|
|
156
|
+
tabOpt(
|
|
157
|
+
program.command("navigate <url>").description("Navigate a tab to a URL.").option("--new", "open in a new tab").option("--inactive", "do not activate the tab")
|
|
158
|
+
).action(async (url, opts) => {
|
|
159
|
+
const args = { url };
|
|
160
|
+
if (opts.tab !== void 0) args.tabId = opts.tab;
|
|
161
|
+
if (opts.new) args.newTab = true;
|
|
162
|
+
if (opts.inactive) args.active = false;
|
|
163
|
+
await run("chrome_navigate", args);
|
|
164
|
+
});
|
|
165
|
+
tabOpt(
|
|
166
|
+
program.command("screenshot").description("Capture a screenshot of the current page.").option("--full", "capture full page").option("-o, --out <path>", "save image to path (base64 PNG decoded)")
|
|
167
|
+
).action(async (opts) => {
|
|
168
|
+
const args = {};
|
|
169
|
+
if (opts.tab !== void 0) args.tabId = opts.tab;
|
|
170
|
+
if (opts.full) args.fullPage = true;
|
|
171
|
+
try {
|
|
172
|
+
const result = await callTool("chrome_screenshot", args);
|
|
173
|
+
if (opts.out && result && typeof result === "object") {
|
|
174
|
+
const data = result.dataUrl ?? result.data;
|
|
175
|
+
if (typeof data === "string") {
|
|
176
|
+
const b64 = data.includes(",") ? data.split(",")[1] : data;
|
|
177
|
+
writeFileSync(opts.out, Buffer.from(b64, "base64"));
|
|
178
|
+
process.stdout.write(`Saved screenshot to ${opts.out}
|
|
179
|
+
`);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
process.stdout.write(JSON.stringify(result, null, 2) + "\n");
|
|
184
|
+
} catch (error) {
|
|
185
|
+
process.stderr.write(
|
|
186
|
+
(error instanceof Error ? error.message : String(error)) + "\n"
|
|
187
|
+
);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
tabOpt(
|
|
192
|
+
program.command("read").description("Extract page structure and interactive elements.").option("-i, --interactive", "return only interactive elements")
|
|
193
|
+
).action(async (opts) => {
|
|
194
|
+
const args = {};
|
|
195
|
+
if (opts.tab !== void 0) args.tabId = opts.tab;
|
|
196
|
+
if (opts.interactive) args.interactiveOnly = true;
|
|
197
|
+
await run("chrome_read_page", args);
|
|
198
|
+
});
|
|
199
|
+
tabOpt(
|
|
200
|
+
program.command("click <selector>").description("Click an element by CSS selector.")
|
|
201
|
+
).action(async (selector, opts) => {
|
|
202
|
+
const args = { selector };
|
|
203
|
+
if (opts.tab !== void 0) args.tabId = opts.tab;
|
|
204
|
+
await run("chrome_click_element", args);
|
|
205
|
+
});
|
|
206
|
+
tabOpt(
|
|
207
|
+
program.command("fill <selector> <value>").description("Fill an input or textarea.")
|
|
208
|
+
).action(async (selector, value, opts) => {
|
|
209
|
+
const args = { selector, value };
|
|
210
|
+
if (opts.tab !== void 0) args.tabId = opts.tab;
|
|
211
|
+
await run("chrome_fill_or_select", args);
|
|
212
|
+
});
|
|
213
|
+
tabOpt(
|
|
214
|
+
program.command("keys <keys>").description("Send keyboard input (e.g. Enter, Meta+L).")
|
|
215
|
+
).action(async (keys, opts) => {
|
|
216
|
+
const args = { keys };
|
|
217
|
+
if (opts.tab !== void 0) args.tabId = opts.tab;
|
|
218
|
+
await run("chrome_keyboard", args);
|
|
219
|
+
});
|
|
220
|
+
program.command("switch <tabId>").description("Activate a tab by ID.").action(async (tabId) => {
|
|
221
|
+
await run("chrome_switch_tab", { tabId: Number(tabId) });
|
|
222
|
+
});
|
|
223
|
+
program.command("close <tabIds...>").description("Close one or more tabs by ID.").action(async (tabIds) => {
|
|
224
|
+
await run("chrome_close_tabs", { tabIds: tabIds.map(Number) });
|
|
225
|
+
});
|
|
226
|
+
program.command("call <tool> [json]").description("Call any Chrome Relay tool with raw JSON args.").action(async (tool, json) => {
|
|
227
|
+
const args = json ? JSON.parse(json) : {};
|
|
228
|
+
await run(tool, args);
|
|
281
229
|
});
|
|
282
|
-
program.
|
|
230
|
+
program.parseAsync(process.argv);
|
package/dist/index.d.ts
CHANGED
package/dist/index.js
CHANGED
package/dist/native-host.js
CHANGED
|
@@ -5,266 +5,43 @@ import process from "process";
|
|
|
5
5
|
|
|
6
6
|
// src/http/server.ts
|
|
7
7
|
import Fastify from "fastify";
|
|
8
|
-
import cors from "@fastify/cors";
|
|
9
|
-
import { randomUUID } from "crypto";
|
|
10
|
-
import {
|
|
11
|
-
StreamableHTTPServerTransport
|
|
12
|
-
} from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
13
|
-
import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
|
|
14
8
|
|
|
15
9
|
// ../protocol/dist/index.js
|
|
16
10
|
var DEFAULT_HTTP_PORT = 12122;
|
|
17
|
-
var TOOL_NAMES = {
|
|
18
|
-
GET_WINDOWS_AND_TABS: "get_windows_and_tabs",
|
|
19
|
-
NAVIGATE: "chrome_navigate",
|
|
20
|
-
SWITCH_TAB: "chrome_switch_tab",
|
|
21
|
-
CLOSE_TABS: "chrome_close_tabs",
|
|
22
|
-
SCREENSHOT: "chrome_screenshot",
|
|
23
|
-
READ_PAGE: "chrome_read_page",
|
|
24
|
-
CLICK: "chrome_click_element",
|
|
25
|
-
FILL: "chrome_fill_or_select",
|
|
26
|
-
KEYBOARD: "chrome_keyboard",
|
|
27
|
-
JAVASCRIPT: "chrome_javascript"
|
|
28
|
-
};
|
|
29
|
-
var TOOL_SCHEMAS = [
|
|
30
|
-
{
|
|
31
|
-
name: TOOL_NAMES.GET_WINDOWS_AND_TABS,
|
|
32
|
-
description: "List open Chrome windows and tabs.",
|
|
33
|
-
inputSchema: { type: "object", properties: {}, required: [] }
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
name: TOOL_NAMES.NAVIGATE,
|
|
37
|
-
description: "Navigate the current tab or a specific tab to a URL.",
|
|
38
|
-
inputSchema: {
|
|
39
|
-
type: "object",
|
|
40
|
-
properties: {
|
|
41
|
-
url: { type: "string", description: "Destination URL." },
|
|
42
|
-
tabId: { type: "number", description: "Optional target tab ID." },
|
|
43
|
-
newTab: { type: "boolean", description: "Open the URL in a new tab." },
|
|
44
|
-
active: { type: "boolean", description: "Whether the navigated tab should be active." }
|
|
45
|
-
},
|
|
46
|
-
required: ["url"]
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
name: TOOL_NAMES.SWITCH_TAB,
|
|
51
|
-
description: "Switch the active browser tab.",
|
|
52
|
-
inputSchema: {
|
|
53
|
-
type: "object",
|
|
54
|
-
properties: {
|
|
55
|
-
tabId: { type: "number", description: "Tab ID to activate." }
|
|
56
|
-
},
|
|
57
|
-
required: ["tabId"]
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
name: TOOL_NAMES.CLOSE_TABS,
|
|
62
|
-
description: "Close one or more tabs.",
|
|
63
|
-
inputSchema: {
|
|
64
|
-
type: "object",
|
|
65
|
-
properties: {
|
|
66
|
-
tabIds: {
|
|
67
|
-
type: "array",
|
|
68
|
-
description: "Tab IDs to close.",
|
|
69
|
-
items: { type: "number" }
|
|
70
|
-
}
|
|
71
|
-
},
|
|
72
|
-
required: ["tabIds"]
|
|
73
|
-
}
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
name: TOOL_NAMES.SCREENSHOT,
|
|
77
|
-
description: "Capture a screenshot of the current page.",
|
|
78
|
-
inputSchema: {
|
|
79
|
-
type: "object",
|
|
80
|
-
properties: {
|
|
81
|
-
tabId: { type: "number", description: "Optional target tab ID." },
|
|
82
|
-
fullPage: { type: "boolean", description: "Capture the full page when supported." }
|
|
83
|
-
},
|
|
84
|
-
required: []
|
|
85
|
-
}
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
name: TOOL_NAMES.READ_PAGE,
|
|
89
|
-
description: "Extract visible page structure and interactive elements.",
|
|
90
|
-
inputSchema: {
|
|
91
|
-
type: "object",
|
|
92
|
-
properties: {
|
|
93
|
-
tabId: { type: "number", description: "Optional target tab ID." },
|
|
94
|
-
interactiveOnly: {
|
|
95
|
-
type: "boolean",
|
|
96
|
-
description: "Return only interactive elements."
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
required: []
|
|
100
|
-
}
|
|
101
|
-
},
|
|
102
|
-
{
|
|
103
|
-
name: TOOL_NAMES.CLICK,
|
|
104
|
-
description: "Click a page element by selector.",
|
|
105
|
-
inputSchema: {
|
|
106
|
-
type: "object",
|
|
107
|
-
properties: {
|
|
108
|
-
selector: { type: "string", description: "CSS selector to click." },
|
|
109
|
-
tabId: { type: "number", description: "Optional target tab ID." }
|
|
110
|
-
},
|
|
111
|
-
required: ["selector"]
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
name: TOOL_NAMES.FILL,
|
|
116
|
-
description: "Fill an input or textarea by selector.",
|
|
117
|
-
inputSchema: {
|
|
118
|
-
type: "object",
|
|
119
|
-
properties: {
|
|
120
|
-
selector: { type: "string", description: "CSS selector to fill." },
|
|
121
|
-
value: { type: "string", description: "Text to insert." },
|
|
122
|
-
tabId: { type: "number", description: "Optional target tab ID." }
|
|
123
|
-
},
|
|
124
|
-
required: ["selector", "value"]
|
|
125
|
-
}
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
name: TOOL_NAMES.KEYBOARD,
|
|
129
|
-
description: "Send keyboard input to the active page.",
|
|
130
|
-
inputSchema: {
|
|
131
|
-
type: "object",
|
|
132
|
-
properties: {
|
|
133
|
-
keys: {
|
|
134
|
-
type: "string",
|
|
135
|
-
description: "Literal key text or chord, for example Enter or Meta+L."
|
|
136
|
-
},
|
|
137
|
-
tabId: { type: "number", description: "Optional target tab ID." }
|
|
138
|
-
},
|
|
139
|
-
required: ["keys"]
|
|
140
|
-
}
|
|
141
|
-
},
|
|
142
|
-
{
|
|
143
|
-
name: TOOL_NAMES.JAVASCRIPT,
|
|
144
|
-
description: "Evaluate JavaScript in the page context.",
|
|
145
|
-
inputSchema: {
|
|
146
|
-
type: "object",
|
|
147
|
-
properties: {
|
|
148
|
-
expression: { type: "string", description: "JavaScript expression to run." },
|
|
149
|
-
tabId: { type: "number", description: "Optional target tab ID." }
|
|
150
|
-
},
|
|
151
|
-
required: ["expression"]
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
];
|
|
155
|
-
|
|
156
|
-
// src/mcp/server.ts
|
|
157
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
158
|
-
import {
|
|
159
|
-
CallToolRequestSchema,
|
|
160
|
-
ListPromptsRequestSchema,
|
|
161
|
-
ListResourcesRequestSchema,
|
|
162
|
-
ListToolsRequestSchema
|
|
163
|
-
} from "@modelcontextprotocol/sdk/types.js";
|
|
164
|
-
function toCallToolResult(data) {
|
|
165
|
-
return {
|
|
166
|
-
content: [
|
|
167
|
-
{
|
|
168
|
-
type: "text",
|
|
169
|
-
text: typeof data === "string" ? data : JSON.stringify(data)
|
|
170
|
-
}
|
|
171
|
-
],
|
|
172
|
-
isError: false
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
function createMcpServer(bridge2) {
|
|
176
|
-
const server2 = new Server(
|
|
177
|
-
{ name: "chrome-relay", version: "0.1.0" },
|
|
178
|
-
{ capabilities: { tools: {}, resources: {}, prompts: {} } }
|
|
179
|
-
);
|
|
180
|
-
server2.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_SCHEMAS }));
|
|
181
|
-
server2.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
|
|
182
|
-
server2.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
|
|
183
|
-
server2.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
184
|
-
try {
|
|
185
|
-
const result = await bridge2.callTool(
|
|
186
|
-
request.params.name,
|
|
187
|
-
request.params.arguments ?? {}
|
|
188
|
-
);
|
|
189
|
-
return toCallToolResult(result);
|
|
190
|
-
} catch (error) {
|
|
191
|
-
return {
|
|
192
|
-
content: [
|
|
193
|
-
{
|
|
194
|
-
type: "text",
|
|
195
|
-
text: error instanceof Error ? error.message : String(error)
|
|
196
|
-
}
|
|
197
|
-
],
|
|
198
|
-
isError: true
|
|
199
|
-
};
|
|
200
|
-
}
|
|
201
|
-
});
|
|
202
|
-
return server2;
|
|
203
|
-
}
|
|
204
11
|
|
|
205
12
|
// src/http/server.ts
|
|
206
13
|
var RelayHttpServer = class {
|
|
207
14
|
constructor(bridge2, port = DEFAULT_HTTP_PORT) {
|
|
15
|
+
this.bridge = bridge2;
|
|
208
16
|
this.port = port;
|
|
209
|
-
this.mcpServer = createMcpServer(bridge2);
|
|
210
17
|
}
|
|
18
|
+
bridge;
|
|
211
19
|
port;
|
|
212
20
|
app = Fastify({ logger: false });
|
|
213
|
-
transports = /* @__PURE__ */ new Map();
|
|
214
|
-
mcpServer;
|
|
215
21
|
async start() {
|
|
216
|
-
await this.app.register(cors, { origin: true });
|
|
217
22
|
this.app.get("/ping", async () => ({ ok: true, port: this.port }));
|
|
218
|
-
this.app.post("/
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
if (!transport) {
|
|
222
|
-
if (sessionId || !isInitializeRequest(request.body)) {
|
|
223
|
-
reply.code(400).send({ error: "Invalid MCP session." });
|
|
224
|
-
return;
|
|
225
|
-
}
|
|
226
|
-
transport = new StreamableHTTPServerTransport({
|
|
227
|
-
sessionIdGenerator: () => randomUUID(),
|
|
228
|
-
onsessioninitialized: (nextSessionId) => {
|
|
229
|
-
if (transport) {
|
|
230
|
-
this.transports.set(nextSessionId, transport);
|
|
231
|
-
}
|
|
232
|
-
}
|
|
233
|
-
});
|
|
234
|
-
transport.onclose = () => {
|
|
235
|
-
if (transport?.sessionId) {
|
|
236
|
-
this.transports.delete(transport.sessionId);
|
|
237
|
-
}
|
|
238
|
-
};
|
|
239
|
-
await this.mcpServer.connect(transport);
|
|
240
|
-
}
|
|
241
|
-
await transport.handleRequest(request.raw, reply.raw, request.body);
|
|
242
|
-
});
|
|
243
|
-
this.app.get("/mcp", async (request, reply) => {
|
|
244
|
-
const sessionId = request.headers["mcp-session-id"];
|
|
245
|
-
if (!sessionId) {
|
|
246
|
-
reply.code(400).send({ error: "Missing MCP session ID." });
|
|
247
|
-
return;
|
|
248
|
-
}
|
|
249
|
-
const transport = this.transports.get(sessionId);
|
|
250
|
-
if (!transport) {
|
|
251
|
-
reply.code(404).send({ error: "Unknown MCP session." });
|
|
23
|
+
this.app.post("/call", async (request, reply) => {
|
|
24
|
+
if (request.headers.origin) {
|
|
25
|
+
reply.code(403).send({ error: "Browser-origin bridge requests are not accepted." });
|
|
252
26
|
return;
|
|
253
27
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
const sessionId = request.headers["mcp-session-id"];
|
|
258
|
-
if (!sessionId) {
|
|
259
|
-
reply.code(400).send({ error: "Missing MCP session ID." });
|
|
28
|
+
const body = request.body ?? {};
|
|
29
|
+
if (typeof body.name !== "string") {
|
|
30
|
+
reply.code(400).send({ ok: false, error: "Missing tool name." });
|
|
260
31
|
return;
|
|
261
32
|
}
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
33
|
+
try {
|
|
34
|
+
const data = await this.bridge.callTool(
|
|
35
|
+
body.name,
|
|
36
|
+
body.args ?? {}
|
|
37
|
+
);
|
|
38
|
+
reply.send({ ok: true, data });
|
|
39
|
+
} catch (error) {
|
|
40
|
+
reply.code(500).send({
|
|
41
|
+
ok: false,
|
|
42
|
+
error: error instanceof Error ? error.message : String(error)
|
|
43
|
+
});
|
|
266
44
|
}
|
|
267
|
-
await transport.handleRequest(request.raw, reply.raw);
|
|
268
45
|
});
|
|
269
46
|
await this.app.listen({ port: this.port, host: "127.0.0.1" });
|
|
270
47
|
}
|
|
@@ -274,7 +51,7 @@ var RelayHttpServer = class {
|
|
|
274
51
|
};
|
|
275
52
|
|
|
276
53
|
// src/native/bridge.ts
|
|
277
|
-
import { randomUUID
|
|
54
|
+
import { randomUUID } from "crypto";
|
|
278
55
|
var ExtensionBridge = class {
|
|
279
56
|
constructor(send) {
|
|
280
57
|
this.send = send;
|
|
@@ -339,7 +116,7 @@ var ExtensionBridge = class {
|
|
|
339
116
|
});
|
|
340
117
|
}
|
|
341
118
|
async ping(timeoutMs = 2e3) {
|
|
342
|
-
const id =
|
|
119
|
+
const id = randomUUID();
|
|
343
120
|
const message = { type: "bridge.ping", id };
|
|
344
121
|
return new Promise((resolve) => {
|
|
345
122
|
const timer = setTimeout(() => {
|
|
@@ -356,7 +133,7 @@ var ExtensionBridge = class {
|
|
|
356
133
|
}
|
|
357
134
|
async callTool(name, args, timeoutMs = 3e4) {
|
|
358
135
|
await this.waitUntilReady();
|
|
359
|
-
const id =
|
|
136
|
+
const id = randomUUID();
|
|
360
137
|
return new Promise((resolve, reject) => {
|
|
361
138
|
const timer = setTimeout(() => {
|
|
362
139
|
this.pending.delete(id);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "chrome-relay",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -15,12 +15,10 @@
|
|
|
15
15
|
"lint": "tsc -p tsconfig.json --noEmit",
|
|
16
16
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
17
17
|
},
|
|
18
|
-
"description": "Connect your local Chrome browser to coding agents through
|
|
19
|
-
"keywords": ["
|
|
18
|
+
"description": "Connect your local Chrome browser to coding agents through a local bridge.",
|
|
19
|
+
"keywords": ["chrome", "browser", "automation", "agents", "native-messaging"],
|
|
20
20
|
"license": "MIT",
|
|
21
21
|
"dependencies": {
|
|
22
|
-
"@fastify/cors": "^11.0.1",
|
|
23
|
-
"@modelcontextprotocol/sdk": "^1.11.0",
|
|
24
22
|
"chalk": "^5.4.1",
|
|
25
23
|
"commander": "^13.1.0",
|
|
26
24
|
"fastify": "^5.3.2"
|
package/dist/stdio.d.ts
DELETED
package/dist/stdio.js
DELETED
|
@@ -1,178 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
|
|
3
|
-
// src/stdio.ts
|
|
4
|
-
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
5
|
-
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
6
|
-
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
7
|
-
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
8
|
-
import {
|
|
9
|
-
CallToolRequestSchema,
|
|
10
|
-
ListPromptsRequestSchema,
|
|
11
|
-
ListResourcesRequestSchema,
|
|
12
|
-
ListToolsRequestSchema
|
|
13
|
-
} from "@modelcontextprotocol/sdk/types.js";
|
|
14
|
-
|
|
15
|
-
// ../protocol/dist/index.js
|
|
16
|
-
var DEFAULT_HTTP_PORT = 12122;
|
|
17
|
-
var TOOL_NAMES = {
|
|
18
|
-
GET_WINDOWS_AND_TABS: "get_windows_and_tabs",
|
|
19
|
-
NAVIGATE: "chrome_navigate",
|
|
20
|
-
SWITCH_TAB: "chrome_switch_tab",
|
|
21
|
-
CLOSE_TABS: "chrome_close_tabs",
|
|
22
|
-
SCREENSHOT: "chrome_screenshot",
|
|
23
|
-
READ_PAGE: "chrome_read_page",
|
|
24
|
-
CLICK: "chrome_click_element",
|
|
25
|
-
FILL: "chrome_fill_or_select",
|
|
26
|
-
KEYBOARD: "chrome_keyboard",
|
|
27
|
-
JAVASCRIPT: "chrome_javascript"
|
|
28
|
-
};
|
|
29
|
-
var TOOL_SCHEMAS = [
|
|
30
|
-
{
|
|
31
|
-
name: TOOL_NAMES.GET_WINDOWS_AND_TABS,
|
|
32
|
-
description: "List open Chrome windows and tabs.",
|
|
33
|
-
inputSchema: { type: "object", properties: {}, required: [] }
|
|
34
|
-
},
|
|
35
|
-
{
|
|
36
|
-
name: TOOL_NAMES.NAVIGATE,
|
|
37
|
-
description: "Navigate the current tab or a specific tab to a URL.",
|
|
38
|
-
inputSchema: {
|
|
39
|
-
type: "object",
|
|
40
|
-
properties: {
|
|
41
|
-
url: { type: "string", description: "Destination URL." },
|
|
42
|
-
tabId: { type: "number", description: "Optional target tab ID." },
|
|
43
|
-
newTab: { type: "boolean", description: "Open the URL in a new tab." },
|
|
44
|
-
active: { type: "boolean", description: "Whether the navigated tab should be active." }
|
|
45
|
-
},
|
|
46
|
-
required: ["url"]
|
|
47
|
-
}
|
|
48
|
-
},
|
|
49
|
-
{
|
|
50
|
-
name: TOOL_NAMES.SWITCH_TAB,
|
|
51
|
-
description: "Switch the active browser tab.",
|
|
52
|
-
inputSchema: {
|
|
53
|
-
type: "object",
|
|
54
|
-
properties: {
|
|
55
|
-
tabId: { type: "number", description: "Tab ID to activate." }
|
|
56
|
-
},
|
|
57
|
-
required: ["tabId"]
|
|
58
|
-
}
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
name: TOOL_NAMES.CLOSE_TABS,
|
|
62
|
-
description: "Close one or more tabs.",
|
|
63
|
-
inputSchema: {
|
|
64
|
-
type: "object",
|
|
65
|
-
properties: {
|
|
66
|
-
tabIds: {
|
|
67
|
-
type: "array",
|
|
68
|
-
description: "Tab IDs to close.",
|
|
69
|
-
items: { type: "number" }
|
|
70
|
-
}
|
|
71
|
-
},
|
|
72
|
-
required: ["tabIds"]
|
|
73
|
-
}
|
|
74
|
-
},
|
|
75
|
-
{
|
|
76
|
-
name: TOOL_NAMES.SCREENSHOT,
|
|
77
|
-
description: "Capture a screenshot of the current page.",
|
|
78
|
-
inputSchema: {
|
|
79
|
-
type: "object",
|
|
80
|
-
properties: {
|
|
81
|
-
tabId: { type: "number", description: "Optional target tab ID." },
|
|
82
|
-
fullPage: { type: "boolean", description: "Capture the full page when supported." }
|
|
83
|
-
},
|
|
84
|
-
required: []
|
|
85
|
-
}
|
|
86
|
-
},
|
|
87
|
-
{
|
|
88
|
-
name: TOOL_NAMES.READ_PAGE,
|
|
89
|
-
description: "Extract visible page structure and interactive elements.",
|
|
90
|
-
inputSchema: {
|
|
91
|
-
type: "object",
|
|
92
|
-
properties: {
|
|
93
|
-
tabId: { type: "number", description: "Optional target tab ID." },
|
|
94
|
-
interactiveOnly: {
|
|
95
|
-
type: "boolean",
|
|
96
|
-
description: "Return only interactive elements."
|
|
97
|
-
}
|
|
98
|
-
},
|
|
99
|
-
required: []
|
|
100
|
-
}
|
|
101
|
-
},
|
|
102
|
-
{
|
|
103
|
-
name: TOOL_NAMES.CLICK,
|
|
104
|
-
description: "Click a page element by selector.",
|
|
105
|
-
inputSchema: {
|
|
106
|
-
type: "object",
|
|
107
|
-
properties: {
|
|
108
|
-
selector: { type: "string", description: "CSS selector to click." },
|
|
109
|
-
tabId: { type: "number", description: "Optional target tab ID." }
|
|
110
|
-
},
|
|
111
|
-
required: ["selector"]
|
|
112
|
-
}
|
|
113
|
-
},
|
|
114
|
-
{
|
|
115
|
-
name: TOOL_NAMES.FILL,
|
|
116
|
-
description: "Fill an input or textarea by selector.",
|
|
117
|
-
inputSchema: {
|
|
118
|
-
type: "object",
|
|
119
|
-
properties: {
|
|
120
|
-
selector: { type: "string", description: "CSS selector to fill." },
|
|
121
|
-
value: { type: "string", description: "Text to insert." },
|
|
122
|
-
tabId: { type: "number", description: "Optional target tab ID." }
|
|
123
|
-
},
|
|
124
|
-
required: ["selector", "value"]
|
|
125
|
-
}
|
|
126
|
-
},
|
|
127
|
-
{
|
|
128
|
-
name: TOOL_NAMES.KEYBOARD,
|
|
129
|
-
description: "Send keyboard input to the active page.",
|
|
130
|
-
inputSchema: {
|
|
131
|
-
type: "object",
|
|
132
|
-
properties: {
|
|
133
|
-
keys: {
|
|
134
|
-
type: "string",
|
|
135
|
-
description: "Literal key text or chord, for example Enter or Meta+L."
|
|
136
|
-
},
|
|
137
|
-
tabId: { type: "number", description: "Optional target tab ID." }
|
|
138
|
-
},
|
|
139
|
-
required: ["keys"]
|
|
140
|
-
}
|
|
141
|
-
},
|
|
142
|
-
{
|
|
143
|
-
name: TOOL_NAMES.JAVASCRIPT,
|
|
144
|
-
description: "Evaluate JavaScript in the page context.",
|
|
145
|
-
inputSchema: {
|
|
146
|
-
type: "object",
|
|
147
|
-
properties: {
|
|
148
|
-
expression: { type: "string", description: "JavaScript expression to run." },
|
|
149
|
-
tabId: { type: "number", description: "Optional target tab ID." }
|
|
150
|
-
},
|
|
151
|
-
required: ["expression"]
|
|
152
|
-
}
|
|
153
|
-
}
|
|
154
|
-
];
|
|
155
|
-
|
|
156
|
-
// src/stdio.ts
|
|
157
|
-
async function runStdioProxy() {
|
|
158
|
-
const client = new Client({ name: "chrome-relay-stdio", version: "0.1.0" }, { capabilities: {} });
|
|
159
|
-
const transport = new StreamableHTTPClientTransport(
|
|
160
|
-
new URL(`http://127.0.0.1:${DEFAULT_HTTP_PORT}/mcp`)
|
|
161
|
-
);
|
|
162
|
-
await client.connect(transport);
|
|
163
|
-
const server = new Server(
|
|
164
|
-
{ name: "chrome-relay-stdio", version: "0.1.0" },
|
|
165
|
-
{ capabilities: { tools: {}, resources: {}, prompts: {} } }
|
|
166
|
-
);
|
|
167
|
-
server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_SCHEMAS }));
|
|
168
|
-
server.setRequestHandler(ListResourcesRequestSchema, async () => ({ resources: [] }));
|
|
169
|
-
server.setRequestHandler(ListPromptsRequestSchema, async () => ({ prompts: [] }));
|
|
170
|
-
server.setRequestHandler(
|
|
171
|
-
CallToolRequestSchema,
|
|
172
|
-
async (request) => client.callTool({ name: request.params.name, arguments: request.params.arguments ?? {} })
|
|
173
|
-
);
|
|
174
|
-
await server.connect(new StdioServerTransport());
|
|
175
|
-
}
|
|
176
|
-
export {
|
|
177
|
-
runStdioProxy
|
|
178
|
-
};
|