chrome-relay 0.1.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/dist/cli.d.ts +1 -0
- package/dist/cli.js +282 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +5 -0
- package/dist/native-host.d.ts +1 -0
- package/dist/native-host.js +419 -0
- package/dist/stdio.d.ts +4 -0
- package/dist/stdio.js +178 -0
- package/package.json +32 -0
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/index.ts
|
|
7
|
+
var CHROME_RELAY_VERSION = "0.1.0";
|
|
8
|
+
|
|
9
|
+
// src/install/install.ts
|
|
10
|
+
import os from "os";
|
|
11
|
+
import path from "path";
|
|
12
|
+
import { chmod, mkdir, readFile, stat, writeFile } from "fs/promises";
|
|
13
|
+
import { fileURLToPath } from "url";
|
|
14
|
+
|
|
15
|
+
// ../protocol/dist/index.js
|
|
16
|
+
var NATIVE_HOST_NAME = "dev.chrome_relay.native_host";
|
|
17
|
+
var DEFAULT_HTTP_PORT = 12122;
|
|
18
|
+
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
|
+
|
|
158
|
+
// src/install/install.ts
|
|
159
|
+
var APP_DIR = path.join(os.homedir(), ".chrome-relay");
|
|
160
|
+
function getChromeManifestDir() {
|
|
161
|
+
if (process.platform === "darwin") {
|
|
162
|
+
return path.join(
|
|
163
|
+
os.homedir(),
|
|
164
|
+
"Library/Application Support/Google/Chrome/NativeMessagingHosts"
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
if (process.platform === "linux") {
|
|
168
|
+
return path.join(os.homedir(), ".config/google-chrome/NativeMessagingHosts");
|
|
169
|
+
}
|
|
170
|
+
throw new Error(`Unsupported platform for install: ${process.platform}`);
|
|
171
|
+
}
|
|
172
|
+
function getDistDir() {
|
|
173
|
+
return path.dirname(fileURLToPath(import.meta.url));
|
|
174
|
+
}
|
|
175
|
+
async function writeWrapperScript(hostPath) {
|
|
176
|
+
await mkdir(APP_DIR, { recursive: true });
|
|
177
|
+
const wrapperPath = path.join(APP_DIR, "run-host.sh");
|
|
178
|
+
const content = `#!/bin/sh
|
|
179
|
+
exec "${process.execPath}" "${hostPath}"
|
|
180
|
+
`;
|
|
181
|
+
await writeFile(wrapperPath, content, "utf8");
|
|
182
|
+
await chmod(wrapperPath, 493);
|
|
183
|
+
return wrapperPath;
|
|
184
|
+
}
|
|
185
|
+
async function writeManifest(wrapperPath) {
|
|
186
|
+
const manifestDir = getChromeManifestDir();
|
|
187
|
+
await mkdir(manifestDir, { recursive: true });
|
|
188
|
+
const manifestPath = path.join(manifestDir, `${NATIVE_HOST_NAME}.json`);
|
|
189
|
+
const manifest = {
|
|
190
|
+
name: NATIVE_HOST_NAME,
|
|
191
|
+
description: "Native host for Chrome Relay",
|
|
192
|
+
path: wrapperPath,
|
|
193
|
+
type: "stdio",
|
|
194
|
+
allowed_origins: [`chrome-extension://${DEFAULT_EXTENSION_ID}/`]
|
|
195
|
+
};
|
|
196
|
+
await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
|
|
197
|
+
`, "utf8");
|
|
198
|
+
return manifestPath;
|
|
199
|
+
}
|
|
200
|
+
async function runInstall() {
|
|
201
|
+
const distDir = getDistDir();
|
|
202
|
+
const hostPath = path.join(distDir, "native-host.js");
|
|
203
|
+
const wrapperPath = await writeWrapperScript(hostPath);
|
|
204
|
+
const manifestPath = await writeManifest(wrapperPath);
|
|
205
|
+
console.log(`Installed Chrome Relay native host.`);
|
|
206
|
+
console.log(`Wrapper: ${wrapperPath}`);
|
|
207
|
+
console.log(`Manifest: ${manifestPath}`);
|
|
208
|
+
console.log(`HTTP MCP endpoint: http://127.0.0.1:${DEFAULT_HTTP_PORT}/mcp`);
|
|
209
|
+
}
|
|
210
|
+
async function runDoctor() {
|
|
211
|
+
try {
|
|
212
|
+
const wrapperPath = path.join(APP_DIR, "run-host.sh");
|
|
213
|
+
const manifestPath = path.join(getChromeManifestDir(), `${NATIVE_HOST_NAME}.json`);
|
|
214
|
+
await stat(wrapperPath);
|
|
215
|
+
await stat(manifestPath);
|
|
216
|
+
const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
|
|
217
|
+
let serverReachable = false;
|
|
218
|
+
try {
|
|
219
|
+
const response = await fetch(`http://127.0.0.1:${DEFAULT_HTTP_PORT}/ping`);
|
|
220
|
+
serverReachable = response.ok;
|
|
221
|
+
} catch {
|
|
222
|
+
serverReachable = false;
|
|
223
|
+
}
|
|
224
|
+
console.log(`Wrapper present: yes`);
|
|
225
|
+
console.log(`Manifest present: yes`);
|
|
226
|
+
console.log(`Allowed origin: ${manifest.allowed_origins?.[0] ?? "missing"}`);
|
|
227
|
+
console.log(`HTTP server reachable: ${serverReachable ? "yes" : "no"}`);
|
|
228
|
+
if (!serverReachable) {
|
|
229
|
+
console.log(`Tip: load the extension so it can launch the native host.`);
|
|
230
|
+
}
|
|
231
|
+
return true;
|
|
232
|
+
} catch (error) {
|
|
233
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
234
|
+
return false;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// src/stdio.ts
|
|
239
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
|
240
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
241
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
242
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
243
|
+
import {
|
|
244
|
+
CallToolRequestSchema,
|
|
245
|
+
ListPromptsRequestSchema,
|
|
246
|
+
ListResourcesRequestSchema,
|
|
247
|
+
ListToolsRequestSchema
|
|
248
|
+
} from "@modelcontextprotocol/sdk/types.js";
|
|
249
|
+
async function runStdioProxy() {
|
|
250
|
+
const client = new Client({ name: "chrome-relay-stdio", version: "0.1.0" }, { capabilities: {} });
|
|
251
|
+
const transport = new StreamableHTTPClientTransport(
|
|
252
|
+
new URL(`http://127.0.0.1:${DEFAULT_HTTP_PORT}/mcp`)
|
|
253
|
+
);
|
|
254
|
+
await client.connect(transport);
|
|
255
|
+
const server = new Server(
|
|
256
|
+
{ name: "chrome-relay-stdio", version: "0.1.0" },
|
|
257
|
+
{ capabilities: { tools: {}, resources: {}, prompts: {} } }
|
|
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());
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// src/cli.ts
|
|
270
|
+
var program = new Command();
|
|
271
|
+
program.name("chrome-relay").description("Connect your local Chrome browser to coding agents through MCP.").version(CHROME_RELAY_VERSION);
|
|
272
|
+
program.command("install").description("Install and register the local Chrome Relay host.").action(async () => {
|
|
273
|
+
await runInstall();
|
|
274
|
+
});
|
|
275
|
+
program.command("doctor").description("Validate the local Chrome Relay installation.").action(async () => {
|
|
276
|
+
const ok = await runDoctor();
|
|
277
|
+
process.exit(ok ? 0 : 1);
|
|
278
|
+
});
|
|
279
|
+
program.command("stdio").description("Expose Chrome Relay over stdio for MCP clients that do not support HTTP.").action(async () => {
|
|
280
|
+
await runStdioProxy();
|
|
281
|
+
});
|
|
282
|
+
program.parse(process.argv);
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
@@ -0,0 +1,419 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/native-host.ts
|
|
4
|
+
import process from "process";
|
|
5
|
+
|
|
6
|
+
// src/http/server.ts
|
|
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
|
+
|
|
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/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
|
+
|
|
205
|
+
// src/http/server.ts
|
|
206
|
+
var RelayHttpServer = class {
|
|
207
|
+
constructor(bridge2, port = DEFAULT_HTTP_PORT) {
|
|
208
|
+
this.port = port;
|
|
209
|
+
this.mcpServer = createMcpServer(bridge2);
|
|
210
|
+
}
|
|
211
|
+
port;
|
|
212
|
+
app = Fastify({ logger: false });
|
|
213
|
+
transports = /* @__PURE__ */ new Map();
|
|
214
|
+
mcpServer;
|
|
215
|
+
async start() {
|
|
216
|
+
await this.app.register(cors, { origin: true });
|
|
217
|
+
this.app.get("/ping", async () => ({ ok: true, port: this.port }));
|
|
218
|
+
this.app.post("/mcp", async (request, reply) => {
|
|
219
|
+
const sessionId = request.headers["mcp-session-id"];
|
|
220
|
+
let transport = sessionId ? this.transports.get(sessionId) : void 0;
|
|
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." });
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
await transport.handleRequest(request.raw, reply.raw);
|
|
255
|
+
});
|
|
256
|
+
this.app.delete("/mcp", async (request, reply) => {
|
|
257
|
+
const sessionId = request.headers["mcp-session-id"];
|
|
258
|
+
if (!sessionId) {
|
|
259
|
+
reply.code(400).send({ error: "Missing MCP session ID." });
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const transport = this.transports.get(sessionId);
|
|
263
|
+
if (!transport) {
|
|
264
|
+
reply.code(404).send({ error: "Unknown MCP session." });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
await transport.handleRequest(request.raw, reply.raw);
|
|
268
|
+
});
|
|
269
|
+
await this.app.listen({ port: this.port, host: "127.0.0.1" });
|
|
270
|
+
}
|
|
271
|
+
async stop() {
|
|
272
|
+
await this.app.close();
|
|
273
|
+
}
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
// src/native/bridge.ts
|
|
277
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
278
|
+
var ExtensionBridge = class {
|
|
279
|
+
constructor(send) {
|
|
280
|
+
this.send = send;
|
|
281
|
+
}
|
|
282
|
+
send;
|
|
283
|
+
pending = /* @__PURE__ */ new Map();
|
|
284
|
+
readyWaiters = /* @__PURE__ */ new Set();
|
|
285
|
+
ready = false;
|
|
286
|
+
handleMessage(message) {
|
|
287
|
+
if (message.type === "bridge.ready") {
|
|
288
|
+
this.handleReady(message);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (message.type === "tool.result") {
|
|
292
|
+
this.handleToolResult(message);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (message.type === "bridge.pong") {
|
|
296
|
+
const pending = this.pending.get(message.id);
|
|
297
|
+
if (!pending) {
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
300
|
+
clearTimeout(pending.timer);
|
|
301
|
+
this.pending.delete(message.id);
|
|
302
|
+
pending.resolve(true);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
handleReady(_message) {
|
|
306
|
+
this.ready = true;
|
|
307
|
+
for (const notify of this.readyWaiters) {
|
|
308
|
+
notify();
|
|
309
|
+
}
|
|
310
|
+
this.readyWaiters.clear();
|
|
311
|
+
}
|
|
312
|
+
handleToolResult(message) {
|
|
313
|
+
const pending = this.pending.get(message.id);
|
|
314
|
+
if (!pending) {
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
clearTimeout(pending.timer);
|
|
318
|
+
this.pending.delete(message.id);
|
|
319
|
+
if (message.payload.ok) {
|
|
320
|
+
pending.resolve(message.payload.data);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
pending.reject(new Error(message.payload.error));
|
|
324
|
+
}
|
|
325
|
+
async waitUntilReady(timeoutMs = 15e3) {
|
|
326
|
+
if (this.ready) {
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
await new Promise((resolve, reject) => {
|
|
330
|
+
const timer = setTimeout(() => {
|
|
331
|
+
this.readyWaiters.delete(onReady);
|
|
332
|
+
reject(new Error("Chrome Relay extension is not connected."));
|
|
333
|
+
}, timeoutMs);
|
|
334
|
+
const onReady = () => {
|
|
335
|
+
clearTimeout(timer);
|
|
336
|
+
resolve();
|
|
337
|
+
};
|
|
338
|
+
this.readyWaiters.add(onReady);
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
async ping(timeoutMs = 2e3) {
|
|
342
|
+
const id = randomUUID2();
|
|
343
|
+
const message = { type: "bridge.ping", id };
|
|
344
|
+
return new Promise((resolve) => {
|
|
345
|
+
const timer = setTimeout(() => {
|
|
346
|
+
this.pending.delete(id);
|
|
347
|
+
resolve(false);
|
|
348
|
+
}, timeoutMs);
|
|
349
|
+
this.pending.set(id, {
|
|
350
|
+
resolve: () => resolve(true),
|
|
351
|
+
reject: () => resolve(false),
|
|
352
|
+
timer
|
|
353
|
+
});
|
|
354
|
+
this.send(message);
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
async callTool(name, args, timeoutMs = 3e4) {
|
|
358
|
+
await this.waitUntilReady();
|
|
359
|
+
const id = randomUUID2();
|
|
360
|
+
return new Promise((resolve, reject) => {
|
|
361
|
+
const timer = setTimeout(() => {
|
|
362
|
+
this.pending.delete(id);
|
|
363
|
+
reject(new Error(`Timed out waiting for tool result: ${name}`));
|
|
364
|
+
}, timeoutMs);
|
|
365
|
+
this.pending.set(id, { resolve, reject, timer });
|
|
366
|
+
this.send({
|
|
367
|
+
type: "tool.call",
|
|
368
|
+
id,
|
|
369
|
+
payload: { name, args }
|
|
370
|
+
});
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// src/native/framing.ts
|
|
376
|
+
function writeNativeMessage(stream, message) {
|
|
377
|
+
const payload = Buffer.from(JSON.stringify(message), "utf8");
|
|
378
|
+
const header = Buffer.alloc(4);
|
|
379
|
+
header.writeUInt32LE(payload.length, 0);
|
|
380
|
+
stream.write(header);
|
|
381
|
+
stream.write(payload);
|
|
382
|
+
}
|
|
383
|
+
function readNativeMessages(stream, onMessage) {
|
|
384
|
+
let buffer = Buffer.alloc(0);
|
|
385
|
+
stream.on("data", (chunk) => {
|
|
386
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
387
|
+
while (buffer.length >= 4) {
|
|
388
|
+
const length = buffer.readUInt32LE(0);
|
|
389
|
+
if (buffer.length < 4 + length) {
|
|
390
|
+
break;
|
|
391
|
+
}
|
|
392
|
+
const payload = buffer.subarray(4, 4 + length);
|
|
393
|
+
buffer = buffer.subarray(4 + length);
|
|
394
|
+
onMessage(JSON.parse(payload.toString("utf8")));
|
|
395
|
+
}
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// src/native-host.ts
|
|
400
|
+
var bridge = new ExtensionBridge((message) => {
|
|
401
|
+
writeNativeMessage(process.stdout, message);
|
|
402
|
+
});
|
|
403
|
+
var server = new RelayHttpServer(bridge);
|
|
404
|
+
async function main() {
|
|
405
|
+
await server.start();
|
|
406
|
+
readNativeMessages(process.stdin, (message) => {
|
|
407
|
+
bridge.handleMessage(message);
|
|
408
|
+
});
|
|
409
|
+
process.stdin.resume();
|
|
410
|
+
process.stdin.on("end", async () => {
|
|
411
|
+
await server.stop();
|
|
412
|
+
process.exit(0);
|
|
413
|
+
});
|
|
414
|
+
}
|
|
415
|
+
main().catch(async (error) => {
|
|
416
|
+
console.error(error);
|
|
417
|
+
await server.stop();
|
|
418
|
+
process.exit(1);
|
|
419
|
+
});
|
package/dist/stdio.d.ts
ADDED
package/dist/stdio.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
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
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "chrome-relay",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "./dist/index.js",
|
|
6
|
+
"types": "./dist/index.d.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"chrome-relay": "./dist/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"dist"
|
|
12
|
+
],
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "tsup",
|
|
15
|
+
"lint": "tsc -p tsconfig.json --noEmit",
|
|
16
|
+
"typecheck": "tsc -p tsconfig.json --noEmit"
|
|
17
|
+
},
|
|
18
|
+
"description": "Connect your local Chrome browser to coding agents through MCP.",
|
|
19
|
+
"keywords": ["mcp", "chrome", "browser", "automation", "agents"],
|
|
20
|
+
"license": "MIT",
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"@fastify/cors": "^11.0.1",
|
|
23
|
+
"@modelcontextprotocol/sdk": "^1.11.0",
|
|
24
|
+
"chalk": "^5.4.1",
|
|
25
|
+
"commander": "^13.1.0",
|
|
26
|
+
"fastify": "^5.3.2"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"@chrome-relay/protocol": "workspace:*",
|
|
30
|
+
"tsup": "^8.4.0"
|
|
31
|
+
}
|
|
32
|
+
}
|