webmcp_everywhere 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/LICENSE +21 -0
- package/README.md +61 -0
- package/chrome_extension/dist/background_service_worker.js +3745 -0
- package/chrome_extension/dist/content_isolated.js +843 -0
- package/chrome_extension/dist/content_main.js +3515 -0
- package/chrome_extension/dist/external_adapter_main.js +3552 -0
- package/chrome_extension/dist/popup.js +508 -0
- package/chrome_extension/manifest.json +24 -0
- package/chrome_extension/user_interface/popup.html +128 -0
- package/install_the_native_messaging_host.mjs +327 -0
- package/native_messaging_template/CONTEXT.md +16 -0
- package/native_messaging_template/com.webmcp_everywhere.host.json +9 -0
- package/package.json +28 -0
- package/webmcp_everywhere.mjs +1166 -0
- package/webmcp_native_host.mjs +17000 -0
- package/webmcp_native_host.sh +51 -0
|
@@ -0,0 +1,1166 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// tools/npm_command_entry.ts
|
|
4
|
+
import Fs7 from "node:fs";
|
|
5
|
+
import Path6 from "node:path";
|
|
6
|
+
|
|
7
|
+
// tools/installation_status.ts
|
|
8
|
+
import Fs6 from "node:fs";
|
|
9
|
+
import Path5 from "node:path";
|
|
10
|
+
|
|
11
|
+
// src/native_messaging_host/host_state_files.ts
|
|
12
|
+
import Crypto from "node:crypto";
|
|
13
|
+
import Fs from "node:fs";
|
|
14
|
+
import Os from "node:os";
|
|
15
|
+
import Path from "node:path";
|
|
16
|
+
var HostStateFiles = class _HostStateFiles {
|
|
17
|
+
static {
|
|
18
|
+
/** Where the endpoint details, the token, and the log are kept, for an agent to read. */
|
|
19
|
+
this.STATE_DIR = process.env.WEBMCP_EVERYWHERE_STATE_DIR ?? Path.join(Os.homedir(), ".webmcp_everywhere");
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Names the one file the bearer token is kept in.
|
|
23
|
+
*
|
|
24
|
+
* @returns The path of `token` inside the state directory.
|
|
25
|
+
*/
|
|
26
|
+
static _tokenPath() {
|
|
27
|
+
return Path.join(_HostStateFiles.STATE_DIR, "token");
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Reads the stored token, creating one on first run.
|
|
31
|
+
*
|
|
32
|
+
* The token persists so an agent configured once keeps working across restarts. This file is the only
|
|
33
|
+
* place it is kept, and every reader is sent here for it.
|
|
34
|
+
*
|
|
35
|
+
* @returns The token.
|
|
36
|
+
*/
|
|
37
|
+
static _readOrCreateToken() {
|
|
38
|
+
Fs.mkdirSync(_HostStateFiles.STATE_DIR, {
|
|
39
|
+
recursive: true,
|
|
40
|
+
mode: 448
|
|
41
|
+
});
|
|
42
|
+
const tokenPath = _HostStateFiles._tokenPath();
|
|
43
|
+
if (Fs.existsSync(tokenPath) === true) {
|
|
44
|
+
return Fs.readFileSync(tokenPath, "utf8").trim();
|
|
45
|
+
}
|
|
46
|
+
const token = Crypto.randomBytes(32).toString("hex");
|
|
47
|
+
Fs.writeFileSync(tokenPath, token, {
|
|
48
|
+
mode: 384
|
|
49
|
+
});
|
|
50
|
+
return token;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Names the file that tells an agent where to go.
|
|
54
|
+
*
|
|
55
|
+
* @returns The path of `endpoint.json` inside the state directory.
|
|
56
|
+
*/
|
|
57
|
+
static _endpointPath() {
|
|
58
|
+
return Path.join(_HostStateFiles.STATE_DIR, "endpoint.json");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Records where the host is listening, so an agent can be pointed at it.
|
|
62
|
+
*
|
|
63
|
+
* Only a host that holds the port writes this file, and it records which process holds it, so the file
|
|
64
|
+
* can be removed by the host that wrote it and by no other.
|
|
65
|
+
*
|
|
66
|
+
* The bearer token is not written here. It never changes, and putting a correct token on the line
|
|
67
|
+
* beside an address that can go stale made the whole file read as authoritative: readers followed it
|
|
68
|
+
* to a port nothing was listening on. The token has one home, `~/.webmcp_everywhere/token`, and this
|
|
69
|
+
* file carries only what is true of the host writing it right now.
|
|
70
|
+
*
|
|
71
|
+
* @param port - The bound port.
|
|
72
|
+
* @returns Nothing.
|
|
73
|
+
*/
|
|
74
|
+
static _writeEndpoint(port) {
|
|
75
|
+
const record = {
|
|
76
|
+
url: `http://127.0.0.1:${port}/mcp`,
|
|
77
|
+
processId: process.pid,
|
|
78
|
+
startedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
79
|
+
};
|
|
80
|
+
Fs.writeFileSync(_HostStateFiles._endpointPath(), JSON.stringify(record, null, " ") + "\n", {
|
|
81
|
+
mode: 384
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Removes `endpoint.json`, but only when it is this host's own.
|
|
86
|
+
*
|
|
87
|
+
* A host that stopped used to leave the file behind, so it went on naming a port nothing was listening
|
|
88
|
+
* on and every agent following the README was pointed at nothing. Checking the process identifier
|
|
89
|
+
* first means a host that has already given the port up to a newer one never removes the newer one's
|
|
90
|
+
* file.
|
|
91
|
+
*
|
|
92
|
+
* @returns Nothing.
|
|
93
|
+
*/
|
|
94
|
+
static _removeEndpointIfOurs() {
|
|
95
|
+
try {
|
|
96
|
+
const record = JSON.parse(
|
|
97
|
+
Fs.readFileSync(_HostStateFiles._endpointPath(), "utf8")
|
|
98
|
+
);
|
|
99
|
+
if (record.processId !== process.pid) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
Fs.unlinkSync(_HostStateFiles._endpointPath());
|
|
103
|
+
} catch {
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// tools/packaged_release_installation.ts
|
|
109
|
+
import Fs5 from "node:fs";
|
|
110
|
+
import Path4 from "node:path";
|
|
111
|
+
|
|
112
|
+
// tools/generate_extension_key.ts
|
|
113
|
+
import Crypto2 from "node:crypto";
|
|
114
|
+
import Fs2 from "node:fs";
|
|
115
|
+
import Path2 from "node:path";
|
|
116
|
+
var __filename = import.meta.filename;
|
|
117
|
+
var __dirname = import.meta.dirname;
|
|
118
|
+
var GenerateExtensionKey = class _GenerateExtensionKey {
|
|
119
|
+
/**
|
|
120
|
+
* Derives Chrome's extension identifier from a public key.
|
|
121
|
+
*
|
|
122
|
+
* Chrome takes the SHA-256 of the DER-encoded public key, keeps the first sixteen bytes, and maps
|
|
123
|
+
* each of the thirty-two nibbles onto the letters `a` to `p`.
|
|
124
|
+
*
|
|
125
|
+
* @param publicKeyDer - The DER-encoded SubjectPublicKeyInfo.
|
|
126
|
+
* @returns The thirty-two character extension identifier.
|
|
127
|
+
*/
|
|
128
|
+
static identifierFromPublicKey(publicKeyDer) {
|
|
129
|
+
const digest = Crypto2.createHash("sha256").update(publicKeyDer).digest();
|
|
130
|
+
let identifier = "";
|
|
131
|
+
for (const byte of digest.subarray(0, 16)) {
|
|
132
|
+
identifier += String.fromCharCode(97 + (byte >> 4));
|
|
133
|
+
identifier += String.fromCharCode(97 + (byte & 15));
|
|
134
|
+
}
|
|
135
|
+
return identifier;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* Generates a key pair, writes the public half into the manifest, and reports the identifier.
|
|
139
|
+
*
|
|
140
|
+
* @returns What was generated and where the private half went.
|
|
141
|
+
*/
|
|
142
|
+
static run() {
|
|
143
|
+
const manifestPath = Path2.join(__dirname, "..", "src", "chrome_extension", "manifest.json");
|
|
144
|
+
const manifest = JSON.parse(Fs2.readFileSync(manifestPath, "utf8"));
|
|
145
|
+
if (manifest.key !== void 0) {
|
|
146
|
+
const identifier = _GenerateExtensionKey.identifierFromPublicKey(
|
|
147
|
+
Buffer.from(manifest.key, "base64")
|
|
148
|
+
);
|
|
149
|
+
return {
|
|
150
|
+
identifier,
|
|
151
|
+
privateKeyPath: "unchanged, the manifest already carries a key"
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const { publicKey, privateKey } = Crypto2.generateKeyPairSync("rsa", {
|
|
155
|
+
modulusLength: 2048
|
|
156
|
+
});
|
|
157
|
+
const publicKeyDer = publicKey.export({
|
|
158
|
+
type: "spki",
|
|
159
|
+
format: "der"
|
|
160
|
+
});
|
|
161
|
+
manifest.key = publicKeyDer.toString("base64");
|
|
162
|
+
Fs2.writeFileSync(manifestPath, JSON.stringify(manifest, null, " ") + "\n");
|
|
163
|
+
const privateKeyPath = Path2.join(__dirname, "..", "extension_private_key.pem");
|
|
164
|
+
Fs2.writeFileSync(
|
|
165
|
+
privateKeyPath,
|
|
166
|
+
privateKey.export({
|
|
167
|
+
type: "pkcs8",
|
|
168
|
+
format: "pem"
|
|
169
|
+
})
|
|
170
|
+
);
|
|
171
|
+
return {
|
|
172
|
+
identifier: _GenerateExtensionKey.identifierFromPublicKey(publicKeyDer),
|
|
173
|
+
privateKeyPath
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Reads the extension identifier the manifest currently pins.
|
|
178
|
+
*
|
|
179
|
+
* @returns The extension identifier.
|
|
180
|
+
* @throws When the manifest carries no key.
|
|
181
|
+
*/
|
|
182
|
+
static currentIdentifier(manifestPath) {
|
|
183
|
+
const readFrom = manifestPath ?? Path2.join(__dirname, "..", "src", "chrome_extension", "manifest.json");
|
|
184
|
+
const manifest = JSON.parse(Fs2.readFileSync(readFrom, "utf8"));
|
|
185
|
+
if (manifest.key === void 0) {
|
|
186
|
+
throw new Error('the manifest has no key; run "node tools/generate_extension_key_entry.ts" first');
|
|
187
|
+
}
|
|
188
|
+
return _GenerateExtensionKey.identifierFromPublicKey(Buffer.from(manifest.key, "base64"));
|
|
189
|
+
}
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// tools/install_native_host.ts
|
|
193
|
+
import Fs3 from "node:fs";
|
|
194
|
+
import Os2 from "node:os";
|
|
195
|
+
import Path3 from "node:path";
|
|
196
|
+
var __filename2 = import.meta.filename;
|
|
197
|
+
var __dirname2 = import.meta.dirname;
|
|
198
|
+
var InstallNativeHost = class _InstallNativeHost {
|
|
199
|
+
static {
|
|
200
|
+
/** The host name the extension asks for, which must match the manifest file name. */
|
|
201
|
+
this.HOST_NAME = "com.webmcp_everywhere.host";
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Works out what an installation would write, without writing any of it.
|
|
205
|
+
*
|
|
206
|
+
* This exists so that the installation can say what it is about to do to the user's machine before it
|
|
207
|
+
* does it. Writing a file into a browser the user installed is not something to announce afterwards.
|
|
208
|
+
*
|
|
209
|
+
* @param options - Where the installation would write.
|
|
210
|
+
* @returns The files the installation would write.
|
|
211
|
+
*/
|
|
212
|
+
static plan(options = {}) {
|
|
213
|
+
const identifier = GenerateExtensionKey.currentIdentifier(options.extensionManifestPath);
|
|
214
|
+
const launcher = _InstallNativeHost._resolveLauncher(options.launcherPath);
|
|
215
|
+
return {
|
|
216
|
+
identifier,
|
|
217
|
+
launcher,
|
|
218
|
+
manifests: _InstallNativeHost.manifestPaths(options)
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Names every manifest file the installation writes, without reading anything else.
|
|
223
|
+
*
|
|
224
|
+
* The installation, the uninstallation and the command that copies a release somewhere stable all
|
|
225
|
+
* need this same list, and a file name spelled in three places is spelled wrong in one of them
|
|
226
|
+
* eventually. It reads no launcher and no extension manifest, so it also answers before an
|
|
227
|
+
* installation exists, which is what lets a command name the files it is about to write.
|
|
228
|
+
*
|
|
229
|
+
* @param options - Which directories to cover.
|
|
230
|
+
* @returns The manifest files, in the order they are written.
|
|
231
|
+
*/
|
|
232
|
+
static manifestPaths(options = {}) {
|
|
233
|
+
return _InstallNativeHost.manifestDirectories(options).map((directory) => {
|
|
234
|
+
return Path3.join(directory, `${_InstallNativeHost.HOST_NAME}.json`);
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Writes the launcher and the manifest.
|
|
239
|
+
*
|
|
240
|
+
* @param options - Where to install.
|
|
241
|
+
* @returns What was written.
|
|
242
|
+
*/
|
|
243
|
+
static run(options = {}) {
|
|
244
|
+
const planned = _InstallNativeHost.plan(options);
|
|
245
|
+
const manifest = _InstallNativeHost._renderManifest(
|
|
246
|
+
planned.launcher,
|
|
247
|
+
planned.identifier,
|
|
248
|
+
options.templateDir
|
|
249
|
+
);
|
|
250
|
+
for (const manifestPath of planned.manifests) {
|
|
251
|
+
Fs3.mkdirSync(Path3.dirname(manifestPath), {
|
|
252
|
+
recursive: true
|
|
253
|
+
});
|
|
254
|
+
Fs3.writeFileSync(manifestPath, manifest);
|
|
255
|
+
}
|
|
256
|
+
return planned;
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Lists every directory Chrome might read host manifests from.
|
|
260
|
+
*
|
|
261
|
+
* The everyday Chrome reads them from its own support directory. A Chrome started with a custom
|
|
262
|
+
* `--user-data-dir`, which is what `LaunchChrome` and the verification runners use, reads them from
|
|
263
|
+
* inside that directory instead and never looks at the everyday one, which is why a throwaway profile
|
|
264
|
+
* can be covered without touching the browser the user installed.
|
|
265
|
+
*
|
|
266
|
+
* This is public because the uninstallation has to remove a manifest from exactly the directories the
|
|
267
|
+
* installation writes one into, and two lists that have to agree are one list.
|
|
268
|
+
*
|
|
269
|
+
* @param options - Which directories to cover.
|
|
270
|
+
* @returns The directories that hold a manifest, the everyday Chrome first when it is covered.
|
|
271
|
+
*/
|
|
272
|
+
static manifestDirectories(options = {}) {
|
|
273
|
+
const directories = [];
|
|
274
|
+
if (options.isEverydayChromeCovered !== false) {
|
|
275
|
+
directories.push(_InstallNativeHost.everydayChromeDirectory());
|
|
276
|
+
}
|
|
277
|
+
for (const userDataDir of options.userDataDirs ?? []) {
|
|
278
|
+
directories.push(Path3.join(userDataDir, "NativeMessagingHosts"));
|
|
279
|
+
}
|
|
280
|
+
return directories;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Names the directory the everyday Chrome, the one the user installed, reads host manifests from.
|
|
284
|
+
*
|
|
285
|
+
* @param homeDir - The home folder to read it out of, for a runner installing into a throwaway one.
|
|
286
|
+
* @returns The absolute path of that directory on this platform.
|
|
287
|
+
*/
|
|
288
|
+
static everydayChromeDirectory(homeDir = Os2.homedir()) {
|
|
289
|
+
if (process.platform === "darwin") {
|
|
290
|
+
return Path3.join(homeDir, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
|
|
291
|
+
}
|
|
292
|
+
return Path3.join(homeDir, ".config", "google-chrome", "NativeMessagingHosts");
|
|
293
|
+
}
|
|
294
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
295
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
296
|
+
// Helpers
|
|
297
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
298
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
299
|
+
/**
|
|
300
|
+
* Fills the host manifest template in with this installation's values.
|
|
301
|
+
*
|
|
302
|
+
* The manifest lives in `data/native_messaging_template/com.webmcp_everywhere.host.json` rather than in
|
|
303
|
+
* this file, so that the shape Chrome reads can be looked at and edited as the JSON document it is. It
|
|
304
|
+
* is read every time instead of being cached, because an installation runs once and then exits. Every
|
|
305
|
+
* placeholder has to be replaced, so an unreplaced one is an error rather than something written out
|
|
306
|
+
* to Chrome, which would refuse the manifest with no useful message.
|
|
307
|
+
*
|
|
308
|
+
* @param launcher - The absolute path to the executable file Chrome starts.
|
|
309
|
+
* @param identifier - The extension identifier the manifest allows to connect.
|
|
310
|
+
* @param templateDir - The folder holding the template, or nothing for this working copy's.
|
|
311
|
+
* @returns The manifest text to write, ending in a newline.
|
|
312
|
+
*/
|
|
313
|
+
static _renderManifest(launcher, identifier, templateDir) {
|
|
314
|
+
const folder = templateDir ?? Path3.join(__dirname2, "..", "data", "native_messaging_template");
|
|
315
|
+
const templatePath = Path3.join(folder, `${_InstallNativeHost.HOST_NAME}.json`);
|
|
316
|
+
if (Fs3.existsSync(templatePath) === false) {
|
|
317
|
+
throw new Error(`host manifest template is missing: ${templatePath}`);
|
|
318
|
+
}
|
|
319
|
+
const template = Fs3.readFileSync(templatePath, "utf8");
|
|
320
|
+
const values = {
|
|
321
|
+
hostName: _InstallNativeHost.HOST_NAME,
|
|
322
|
+
launcherPath: launcher,
|
|
323
|
+
extensionIdentifier: identifier
|
|
324
|
+
};
|
|
325
|
+
let rendered = template;
|
|
326
|
+
for (const [placeholder, value] of Object.entries(values)) {
|
|
327
|
+
rendered = rendered.split(`{{${placeholder}}}`).join(value);
|
|
328
|
+
}
|
|
329
|
+
const leftover = rendered.match(/\{\{[^}]*\}\}/);
|
|
330
|
+
if (leftover !== null) {
|
|
331
|
+
throw new Error(`host manifest template has an unknown placeholder: ${leftover[0]}`);
|
|
332
|
+
}
|
|
333
|
+
JSON.parse(rendered);
|
|
334
|
+
return rendered.endsWith("\n") === true ? rendered : rendered + "\n";
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Locates the executable Chrome actually launches.
|
|
338
|
+
*
|
|
339
|
+
* Chrome runs the path in the manifest directly, so it has to be an executable file rather than a
|
|
340
|
+
* script it would have to know how to interpret. `bin/webmcp_native_host.sh` is that file, it is
|
|
341
|
+
* kept in the repository, and it works out the rest of the paths on its own, so this only has to
|
|
342
|
+
* check that it is there and that it is executable.
|
|
343
|
+
*
|
|
344
|
+
* @param named - A launcher to use instead of this working copy's, such as a packaged release's.
|
|
345
|
+
* @returns The absolute path to the launcher.
|
|
346
|
+
* @throws When the launcher is not there.
|
|
347
|
+
*/
|
|
348
|
+
static _resolveLauncher(named) {
|
|
349
|
+
const repoRoot = Path3.join(__dirname2, "..");
|
|
350
|
+
const launcher = named === void 0 ? Path3.join(repoRoot, "bin", "webmcp_native_host.sh") : Path3.resolve(named);
|
|
351
|
+
if (Fs3.existsSync(launcher) === false) {
|
|
352
|
+
throw new Error(`launcher is missing: ${launcher}`);
|
|
353
|
+
}
|
|
354
|
+
Fs3.chmodSync(launcher, 493);
|
|
355
|
+
return launcher;
|
|
356
|
+
}
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
// tools/release_layout.ts
|
|
360
|
+
var ReleaseLayout = class {
|
|
361
|
+
static {
|
|
362
|
+
/** The bundled native messaging host, one file with its dependencies inlined. */
|
|
363
|
+
this.HOST_BUNDLE = "webmcp_native_host.mjs";
|
|
364
|
+
}
|
|
365
|
+
static {
|
|
366
|
+
/** The launcher Chrome starts, which finds a Node.js and runs the bundle beside it. */
|
|
367
|
+
this.LAUNCHER = "webmcp_native_host.sh";
|
|
368
|
+
}
|
|
369
|
+
static {
|
|
370
|
+
/** The installer that registers that launcher with Chrome. */
|
|
371
|
+
this.INSTALLER = "install_the_native_messaging_host.mjs";
|
|
372
|
+
}
|
|
373
|
+
static {
|
|
374
|
+
/** The command an `npx webmcp_everywhere` run starts, which the `bin` field of the manifest names. */
|
|
375
|
+
this.COMMAND = "webmcp_everywhere.mjs";
|
|
376
|
+
}
|
|
377
|
+
static {
|
|
378
|
+
/** The manifest npm publishes the folder with. */
|
|
379
|
+
this.PACKAGE_MANIFEST = "package.json";
|
|
380
|
+
}
|
|
381
|
+
static {
|
|
382
|
+
/** The folder a person loads at `chrome://extensions`. */
|
|
383
|
+
this.EXTENSION_DIR = "chrome_extension";
|
|
384
|
+
}
|
|
385
|
+
static {
|
|
386
|
+
/** The folder holding the host manifest template the installer fills in. */
|
|
387
|
+
this.TEMPLATE_DIR = "native_messaging_template";
|
|
388
|
+
}
|
|
389
|
+
static {
|
|
390
|
+
/** The extension manifest inside the extension folder, which pins the extension identifier. */
|
|
391
|
+
this.EXTENSION_MANIFEST = "manifest.json";
|
|
392
|
+
}
|
|
393
|
+
};
|
|
394
|
+
|
|
395
|
+
// tools/uninstall_native_host.ts
|
|
396
|
+
import Fs4 from "node:fs";
|
|
397
|
+
var UninstallNativeHost = class _UninstallNativeHost {
|
|
398
|
+
/**
|
|
399
|
+
* Removes the manifest from every directory the installation writes it into.
|
|
400
|
+
*
|
|
401
|
+
* @param options - Where to uninstall from.
|
|
402
|
+
* @returns Every manifest file looked at, and whether each one was there.
|
|
403
|
+
*/
|
|
404
|
+
static run(options = {}) {
|
|
405
|
+
const manifests = [];
|
|
406
|
+
for (const manifestPath of InstallNativeHost.manifestPaths(options)) {
|
|
407
|
+
const launcher = _UninstallNativeHost._readLauncher(manifestPath);
|
|
408
|
+
let isRemoved = false;
|
|
409
|
+
if (Fs4.existsSync(manifestPath) === true) {
|
|
410
|
+
Fs4.rmSync(manifestPath);
|
|
411
|
+
isRemoved = true;
|
|
412
|
+
}
|
|
413
|
+
manifests.push({
|
|
414
|
+
path: manifestPath,
|
|
415
|
+
isRemoved,
|
|
416
|
+
launcher
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
return {
|
|
420
|
+
manifests,
|
|
421
|
+
stateDir: HostStateFiles.STATE_DIR
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
425
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
426
|
+
// Helpers
|
|
427
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
428
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
429
|
+
/**
|
|
430
|
+
* Reads the executable a manifest names, before that manifest is removed.
|
|
431
|
+
*
|
|
432
|
+
* A manifest another program wrote, or one this project wrote and then half overwrote, is still a
|
|
433
|
+
* file to remove, so an unreadable one is reported as an unknown launcher rather than refused.
|
|
434
|
+
*
|
|
435
|
+
* @param manifestPath - The manifest file to read.
|
|
436
|
+
* @returns The path in the manifest's `path` field, or null when there is none to read.
|
|
437
|
+
*/
|
|
438
|
+
static _readLauncher(manifestPath) {
|
|
439
|
+
if (Fs4.existsSync(manifestPath) === false) {
|
|
440
|
+
return null;
|
|
441
|
+
}
|
|
442
|
+
try {
|
|
443
|
+
const parsed = JSON.parse(Fs4.readFileSync(manifestPath, "utf8"));
|
|
444
|
+
if (typeof parsed.path === "string") {
|
|
445
|
+
return parsed.path;
|
|
446
|
+
}
|
|
447
|
+
return null;
|
|
448
|
+
} catch {
|
|
449
|
+
return null;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
// tools/packaged_release_installation.ts
|
|
455
|
+
var PackagedReleaseInstallation = class _PackagedReleaseInstallation {
|
|
456
|
+
static {
|
|
457
|
+
/** The folder inside the state directory that the release is copied into. */
|
|
458
|
+
this.FOLDER_NAME = "installation";
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Names the folder an installation goes into.
|
|
462
|
+
*
|
|
463
|
+
* @param options - Where the installation reads from and writes to.
|
|
464
|
+
* @returns The target folder, which is the state directory's `installation` unless one was named.
|
|
465
|
+
*/
|
|
466
|
+
static targetDir(options) {
|
|
467
|
+
return options.targetDir ?? Path4.join(HostStateFiles.STATE_DIR, _PackagedReleaseInstallation.FOLDER_NAME);
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Works out everything an installation would write, without writing any of it.
|
|
471
|
+
*
|
|
472
|
+
* This is what lets the command name every path it is about to touch before it touches one. A copy
|
|
473
|
+
* into the user's home folder and a file written into the browser they installed are both things to
|
|
474
|
+
* announce beforehand, not afterwards.
|
|
475
|
+
*
|
|
476
|
+
* @param options - Where the installation reads from and writes to.
|
|
477
|
+
* @returns The folders and files the installation would write.
|
|
478
|
+
*/
|
|
479
|
+
static plan(options) {
|
|
480
|
+
const targetDir = _PackagedReleaseInstallation.targetDir(options);
|
|
481
|
+
const isAlreadyInPlace = Path4.resolve(options.sourceDir) === Path4.resolve(targetDir);
|
|
482
|
+
const nativeHost = {
|
|
483
|
+
identifier: GenerateExtensionKey.currentIdentifier(
|
|
484
|
+
Path4.join(options.sourceDir, ReleaseLayout.EXTENSION_DIR, ReleaseLayout.EXTENSION_MANIFEST)
|
|
485
|
+
),
|
|
486
|
+
launcher: Path4.join(targetDir, ReleaseLayout.LAUNCHER),
|
|
487
|
+
manifests: InstallNativeHost.manifestPaths({
|
|
488
|
+
isEverydayChromeCovered: options.isEverydayChromeCovered,
|
|
489
|
+
userDataDirs: options.userDataDirs
|
|
490
|
+
})
|
|
491
|
+
};
|
|
492
|
+
return {
|
|
493
|
+
sourceDir: options.sourceDir,
|
|
494
|
+
targetDir,
|
|
495
|
+
isTargetDirReplaced: Fs5.existsSync(targetDir) === true && isAlreadyInPlace === false,
|
|
496
|
+
isAlreadyInPlace,
|
|
497
|
+
extensionDir: Path4.join(targetDir, ReleaseLayout.EXTENSION_DIR),
|
|
498
|
+
stateDir: HostStateFiles.STATE_DIR,
|
|
499
|
+
nativeHost
|
|
500
|
+
};
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* Copies the release into the target folder, then registers its launcher with Chrome.
|
|
504
|
+
*
|
|
505
|
+
* Running it a second time replaces the folder rather than adding a second one beside it, because a
|
|
506
|
+
* user updating to a newer version is the ordinary case and two installations would leave Chrome
|
|
507
|
+
* pointing at whichever was registered last with no way to tell which.
|
|
508
|
+
*
|
|
509
|
+
* @param options - Where the installation reads from and writes to.
|
|
510
|
+
* @returns What was written, in the same shape `plan` returns.
|
|
511
|
+
*/
|
|
512
|
+
static install(options) {
|
|
513
|
+
const planned = _PackagedReleaseInstallation.plan(options);
|
|
514
|
+
if (planned.isAlreadyInPlace === false) {
|
|
515
|
+
Fs5.rmSync(planned.targetDir, {
|
|
516
|
+
recursive: true,
|
|
517
|
+
force: true
|
|
518
|
+
});
|
|
519
|
+
Fs5.mkdirSync(Path4.dirname(planned.targetDir), {
|
|
520
|
+
recursive: true,
|
|
521
|
+
mode: 448
|
|
522
|
+
});
|
|
523
|
+
Fs5.cpSync(planned.sourceDir, planned.targetDir, {
|
|
524
|
+
recursive: true
|
|
525
|
+
});
|
|
526
|
+
}
|
|
527
|
+
_PackagedReleaseInstallation._makeRunnable(Path4.join(planned.targetDir, ReleaseLayout.LAUNCHER));
|
|
528
|
+
_PackagedReleaseInstallation._makeRunnable(Path4.join(planned.targetDir, ReleaseLayout.COMMAND));
|
|
529
|
+
InstallNativeHost.run({
|
|
530
|
+
launcherPath: Path4.join(planned.targetDir, ReleaseLayout.LAUNCHER),
|
|
531
|
+
templateDir: Path4.join(planned.targetDir, ReleaseLayout.TEMPLATE_DIR),
|
|
532
|
+
extensionManifestPath: Path4.join(
|
|
533
|
+
planned.targetDir,
|
|
534
|
+
ReleaseLayout.EXTENSION_DIR,
|
|
535
|
+
ReleaseLayout.EXTENSION_MANIFEST
|
|
536
|
+
),
|
|
537
|
+
isEverydayChromeCovered: options.isEverydayChromeCovered,
|
|
538
|
+
userDataDirs: options.userDataDirs
|
|
539
|
+
});
|
|
540
|
+
return planned;
|
|
541
|
+
}
|
|
542
|
+
/**
|
|
543
|
+
* Takes the registration and the installation folder back out, and leaves everything else.
|
|
544
|
+
*
|
|
545
|
+
* The token and the loaded adapters are not removed. They are the user's, they took a decision each
|
|
546
|
+
* to create, and a command that installed a browser extension has no business deleting them.
|
|
547
|
+
*
|
|
548
|
+
* @param options - Where the installation was written, and which Chrome it covered.
|
|
549
|
+
* @returns Every manifest looked at, whether the folder was removed, and the state directory left alone.
|
|
550
|
+
*/
|
|
551
|
+
static remove(options) {
|
|
552
|
+
const targetDir = _PackagedReleaseInstallation.targetDir(options);
|
|
553
|
+
const uninstalled = UninstallNativeHost.run({
|
|
554
|
+
isEverydayChromeCovered: options.isEverydayChromeCovered,
|
|
555
|
+
userDataDirs: options.userDataDirs
|
|
556
|
+
});
|
|
557
|
+
const isTargetDirRemoved = Fs5.existsSync(targetDir);
|
|
558
|
+
if (isTargetDirRemoved === true) {
|
|
559
|
+
Fs5.rmSync(targetDir, {
|
|
560
|
+
recursive: true,
|
|
561
|
+
force: true
|
|
562
|
+
});
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
targetDir,
|
|
566
|
+
isTargetDirRemoved,
|
|
567
|
+
manifests: uninstalled.manifests,
|
|
568
|
+
stateDir: uninstalled.stateDir
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
572
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
573
|
+
// Helpers
|
|
574
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
575
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
576
|
+
/**
|
|
577
|
+
* Makes one copied file executable again.
|
|
578
|
+
*
|
|
579
|
+
* A copy does not always carry the executable bit through, and neither does every way a package
|
|
580
|
+
* reaches a machine. Chrome starting the launcher and a person running the command are both silent
|
|
581
|
+
* failures when it is missing, so it is set rather than assumed.
|
|
582
|
+
*
|
|
583
|
+
* @param path - The file to make executable.
|
|
584
|
+
* @returns Nothing.
|
|
585
|
+
*/
|
|
586
|
+
static _makeRunnable(path) {
|
|
587
|
+
if (Fs5.existsSync(path) === false) {
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
Fs5.chmodSync(path, 493);
|
|
591
|
+
}
|
|
592
|
+
};
|
|
593
|
+
|
|
594
|
+
// src/adapter_format/tool_naming.ts
|
|
595
|
+
var ToolNaming = class _ToolNaming {
|
|
596
|
+
static {
|
|
597
|
+
/** Separates the site slug from the unqualified tool name. Two underscores, so single ones are free. */
|
|
598
|
+
this.SEPARATOR = "__";
|
|
599
|
+
}
|
|
600
|
+
static {
|
|
601
|
+
/**
|
|
602
|
+
* The site slug the browser's own tools are qualified with, which belongs to no adapter.
|
|
603
|
+
*
|
|
604
|
+
* `list_pages`, `open_page` and `close_page` are answered by the bridge rather than by any page, so
|
|
605
|
+
* anything counting adapters has to tell them apart from an adapter's tools. The qualified names
|
|
606
|
+
* themselves are spelled out in `native_bridge.ts` and in `webmcp_native_host.ts`, which is where they
|
|
607
|
+
* are offered from.
|
|
608
|
+
*/
|
|
609
|
+
this.BROWSER_SLUG = "webmcp_everywhere";
|
|
610
|
+
}
|
|
611
|
+
static {
|
|
612
|
+
/** Names WebMCP accepts. Anything outside this set is rejected before registration is attempted. */
|
|
613
|
+
this.VALID_NAME = /^[a-z0-9_]+$/;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Joins a site slug and an unqualified tool name into the name actually registered with WebMCP.
|
|
617
|
+
*
|
|
618
|
+
* @param siteSlug - The adapter's site slug, for example `demo_playwright_dev`.
|
|
619
|
+
* @param toolName - The unqualified tool name, for example `list_todos`.
|
|
620
|
+
* @returns The qualified name, for example `demo_playwright_dev__list_todos`.
|
|
621
|
+
*/
|
|
622
|
+
static qualify(siteSlug, toolName) {
|
|
623
|
+
return `${siteSlug}${_ToolNaming.SEPARATOR}${toolName}`;
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* Splits a qualified name back into its site slug and unqualified tool name.
|
|
627
|
+
*
|
|
628
|
+
* @param qualifiedName - A name such as `demo_playwright_dev__list_todos`.
|
|
629
|
+
* @returns The two parts, or `null` when the name is not qualified.
|
|
630
|
+
*/
|
|
631
|
+
static unqualify(qualifiedName) {
|
|
632
|
+
const index = qualifiedName.indexOf(_ToolNaming.SEPARATOR);
|
|
633
|
+
if (index === -1) {
|
|
634
|
+
return null;
|
|
635
|
+
}
|
|
636
|
+
return {
|
|
637
|
+
siteSlug: qualifiedName.slice(0, index),
|
|
638
|
+
toolName: qualifiedName.slice(index + _ToolNaming.SEPARATOR.length)
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Reports whether a qualified name belongs to the given adapter.
|
|
643
|
+
*
|
|
644
|
+
* @param qualifiedName - The name to test.
|
|
645
|
+
* @param siteSlug - The adapter's site slug.
|
|
646
|
+
* @returns `true` when the name was registered by that adapter.
|
|
647
|
+
*/
|
|
648
|
+
static belongsTo(qualifiedName, siteSlug) {
|
|
649
|
+
return qualifiedName.startsWith(siteSlug + _ToolNaming.SEPARATOR);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
// tools/installation_status.ts
|
|
654
|
+
var InstallationStatus = class _InstallationStatus {
|
|
655
|
+
static {
|
|
656
|
+
/** How long to wait for the host to answer, in milliseconds, when the caller names nothing. */
|
|
657
|
+
this.TIMEOUT = 2e3;
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Walks the delivery path as far as it goes, and says where it stopped.
|
|
661
|
+
*
|
|
662
|
+
* @param options - Where to look, and how long to wait.
|
|
663
|
+
* @returns What was found at every step.
|
|
664
|
+
*/
|
|
665
|
+
static async read(options = {}) {
|
|
666
|
+
const stateDir = options.stateDir ?? HostStateFiles.STATE_DIR;
|
|
667
|
+
const installationDir = Path5.join(stateDir, PackagedReleaseInstallation.FOLDER_NAME);
|
|
668
|
+
const extensionDir = Path5.join(installationDir, ReleaseLayout.EXTENSION_DIR);
|
|
669
|
+
const timeout = options.timeoutMilliseconds ?? _InstallationStatus.TIMEOUT;
|
|
670
|
+
const partial = {
|
|
671
|
+
installationDir,
|
|
672
|
+
extensionDir,
|
|
673
|
+
stateDir,
|
|
674
|
+
endpoint: null,
|
|
675
|
+
isExtensionConnected: false,
|
|
676
|
+
toolNames: [],
|
|
677
|
+
adapters: [],
|
|
678
|
+
browserToolCount: 0
|
|
679
|
+
};
|
|
680
|
+
const endpoint = _InstallationStatus._readEndpoint(stateDir);
|
|
681
|
+
if (endpoint === null) {
|
|
682
|
+
if (Fs6.existsSync(installationDir) === false) {
|
|
683
|
+
return {
|
|
684
|
+
...partial,
|
|
685
|
+
isReady: false,
|
|
686
|
+
stage: "nothing_installed",
|
|
687
|
+
summary: `Nothing is installed at ${installationDir}`,
|
|
688
|
+
remedy: ["Install it: npx webmcp_everywhere"]
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
return {
|
|
692
|
+
...partial,
|
|
693
|
+
isReady: false,
|
|
694
|
+
stage: "no_host_listening",
|
|
695
|
+
summary: "No browser is holding the port, so the extension is not loaded or Chrome is not running.",
|
|
696
|
+
remedy: _InstallationStatus._loadTheExtension(extensionDir)
|
|
697
|
+
};
|
|
698
|
+
}
|
|
699
|
+
const health = await _InstallationStatus._askHealth(endpoint.url, timeout);
|
|
700
|
+
if (health === null) {
|
|
701
|
+
return {
|
|
702
|
+
...partial,
|
|
703
|
+
endpoint,
|
|
704
|
+
isReady: false,
|
|
705
|
+
stage: "nothing_answers_the_recorded_address",
|
|
706
|
+
summary: `${endpoint.url} was recorded by process ${endpoint.processId}, and nothing answers there.`,
|
|
707
|
+
remedy: ["Quit Chrome and start it again, which starts a new host."]
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
if (health.processId !== endpoint.processId) {
|
|
711
|
+
return {
|
|
712
|
+
...partial,
|
|
713
|
+
endpoint,
|
|
714
|
+
isReady: false,
|
|
715
|
+
stage: "another_program_holds_the_port",
|
|
716
|
+
summary: `Process ${health.processId} is answering on ${endpoint.url}, not the host that recorded it.`,
|
|
717
|
+
remedy: [
|
|
718
|
+
"The port serves one browser at a time. Close the other browser holding it,",
|
|
719
|
+
"then quit Chrome and start it again."
|
|
720
|
+
]
|
|
721
|
+
};
|
|
722
|
+
}
|
|
723
|
+
if (health.extensionConnected === false) {
|
|
724
|
+
return {
|
|
725
|
+
...partial,
|
|
726
|
+
endpoint,
|
|
727
|
+
isReady: false,
|
|
728
|
+
stage: "extension_not_connected",
|
|
729
|
+
summary: `A host is listening on ${endpoint.url}, and no extension is connected to it.`,
|
|
730
|
+
remedy: _InstallationStatus._loadTheExtension(extensionDir)
|
|
731
|
+
};
|
|
732
|
+
}
|
|
733
|
+
const tokenPath = Path5.join(stateDir, "token");
|
|
734
|
+
if (Fs6.existsSync(tokenPath) === false) {
|
|
735
|
+
return {
|
|
736
|
+
...partial,
|
|
737
|
+
endpoint,
|
|
738
|
+
isExtensionConnected: true,
|
|
739
|
+
isReady: false,
|
|
740
|
+
stage: "no_token",
|
|
741
|
+
summary: `There is no bearer token at ${tokenPath}, so nothing may ask the host anything.`,
|
|
742
|
+
remedy: ["Quit Chrome and start it again, which writes one."]
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
const token = Fs6.readFileSync(tokenPath, "utf8").trim();
|
|
746
|
+
const answer = await _InstallationStatus._askTools(endpoint.url, token, timeout);
|
|
747
|
+
if (answer.isTokenRefused === true) {
|
|
748
|
+
return {
|
|
749
|
+
...partial,
|
|
750
|
+
endpoint,
|
|
751
|
+
isExtensionConnected: true,
|
|
752
|
+
isReady: false,
|
|
753
|
+
stage: "token_refused",
|
|
754
|
+
summary: `The host refused the token in ${tokenPath}`,
|
|
755
|
+
remedy: ["Quit Chrome and start it again, then read the token again."]
|
|
756
|
+
};
|
|
757
|
+
}
|
|
758
|
+
const adapters = _InstallationStatus._groupByAdapter(answer.toolNames);
|
|
759
|
+
const browserToolCount = answer.toolNames.filter((name) => {
|
|
760
|
+
return ToolNaming.belongsTo(name, ToolNaming.BROWSER_SLUG) === true;
|
|
761
|
+
}).length;
|
|
762
|
+
const siteToolCount = answer.toolNames.length - browserToolCount;
|
|
763
|
+
if (answer.toolNames.length === 0) {
|
|
764
|
+
return {
|
|
765
|
+
...partial,
|
|
766
|
+
endpoint,
|
|
767
|
+
isExtensionConnected: true,
|
|
768
|
+
isReady: false,
|
|
769
|
+
stage: "no_tools_offered",
|
|
770
|
+
summary: "The extension is connected and the host offered no tools at all, not even its own.",
|
|
771
|
+
remedy: ["Quit Chrome and start it again."]
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
const answered = {
|
|
775
|
+
...partial,
|
|
776
|
+
endpoint,
|
|
777
|
+
isExtensionConnected: true,
|
|
778
|
+
toolNames: answer.toolNames,
|
|
779
|
+
adapters,
|
|
780
|
+
browserToolCount,
|
|
781
|
+
isReady: true
|
|
782
|
+
};
|
|
783
|
+
if (adapters.length === 0) {
|
|
784
|
+
return {
|
|
785
|
+
...answered,
|
|
786
|
+
stage: "no_site_adapter_running",
|
|
787
|
+
summary: `The extension is loaded and connected, and ${browserToolCount} browser tools are reaching your agent. No open tab has a site adapter running in it.`,
|
|
788
|
+
remedy: [
|
|
789
|
+
"Open a site one of your adapters covers, then ask again. Your agent can open one itself",
|
|
790
|
+
"with the webmcp_everywhere__open_page tool.",
|
|
791
|
+
"The extension popup lists every adapter and says why a withheld one is withheld."
|
|
792
|
+
]
|
|
793
|
+
};
|
|
794
|
+
}
|
|
795
|
+
return {
|
|
796
|
+
...answered,
|
|
797
|
+
stage: "tools_offered",
|
|
798
|
+
summary: `${siteToolCount} tools from ${adapters.length} ${adapters.length === 1 ? "adapter" : "adapters"} are reaching your agent, and ${browserToolCount} browser tools beside them.`,
|
|
799
|
+
remedy: []
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
803
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
804
|
+
// Helpers
|
|
805
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
806
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
807
|
+
/**
|
|
808
|
+
* Reads where a host said it is listening.
|
|
809
|
+
*
|
|
810
|
+
* The file is there only while a host really holds the port, and it is removed by the host that
|
|
811
|
+
* wrote it, so a missing file means no browser is running rather than an address gone stale.
|
|
812
|
+
*
|
|
813
|
+
* @param stateDir - The state directory to read it from.
|
|
814
|
+
* @returns The record, or null when there is no readable one.
|
|
815
|
+
*/
|
|
816
|
+
static _readEndpoint(stateDir) {
|
|
817
|
+
const endpointPath = Path5.join(stateDir, "endpoint.json");
|
|
818
|
+
if (Fs6.existsSync(endpointPath) === false) {
|
|
819
|
+
return null;
|
|
820
|
+
}
|
|
821
|
+
try {
|
|
822
|
+
return JSON.parse(Fs6.readFileSync(endpointPath, "utf8"));
|
|
823
|
+
} catch {
|
|
824
|
+
return null;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Asks whatever is on that address what it is and whether an extension is connected to it.
|
|
829
|
+
*
|
|
830
|
+
* @param url - The Model Context Protocol address the endpoint file names.
|
|
831
|
+
* @param timeoutMilliseconds - How long to wait for an answer.
|
|
832
|
+
* @returns What answered, or null when nothing did.
|
|
833
|
+
*/
|
|
834
|
+
static async _askHealth(url, timeoutMilliseconds) {
|
|
835
|
+
try {
|
|
836
|
+
const response = await fetch(new URL("/health", url), {
|
|
837
|
+
signal: AbortSignal.timeout(timeoutMilliseconds)
|
|
838
|
+
});
|
|
839
|
+
return await response.json();
|
|
840
|
+
} catch {
|
|
841
|
+
return null;
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
/**
|
|
845
|
+
* Asks the host for its tool list, exactly the way an agent does.
|
|
846
|
+
*
|
|
847
|
+
* @param url - The Model Context Protocol address.
|
|
848
|
+
* @param token - The bearer token to present.
|
|
849
|
+
* @param timeoutMilliseconds - How long to wait for an answer.
|
|
850
|
+
* @returns The names offered, and whether the token was refused.
|
|
851
|
+
*/
|
|
852
|
+
static async _askTools(url, token, timeoutMilliseconds) {
|
|
853
|
+
try {
|
|
854
|
+
const response = await fetch(url, {
|
|
855
|
+
method: "POST",
|
|
856
|
+
headers: {
|
|
857
|
+
"content-type": "application/json",
|
|
858
|
+
accept: "application/json, text/event-stream",
|
|
859
|
+
authorization: `Bearer ${token}`
|
|
860
|
+
},
|
|
861
|
+
body: JSON.stringify({
|
|
862
|
+
jsonrpc: "2.0",
|
|
863
|
+
id: 1,
|
|
864
|
+
method: "tools/list",
|
|
865
|
+
params: {}
|
|
866
|
+
}),
|
|
867
|
+
signal: AbortSignal.timeout(timeoutMilliseconds)
|
|
868
|
+
});
|
|
869
|
+
if (response.status === 401 || response.status === 403) {
|
|
870
|
+
return {
|
|
871
|
+
toolNames: [],
|
|
872
|
+
isTokenRefused: true
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
const body = await response.json();
|
|
876
|
+
const toolNames = (body.result?.tools ?? []).map((tool) => tool.name ?? "").filter((name) => name.length > 0);
|
|
877
|
+
return {
|
|
878
|
+
toolNames,
|
|
879
|
+
isTokenRefused: false
|
|
880
|
+
};
|
|
881
|
+
} catch {
|
|
882
|
+
return {
|
|
883
|
+
toolNames: [],
|
|
884
|
+
isTokenRefused: false
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
/**
|
|
889
|
+
* Sorts tool names into the adapters that registered them, and the tabs they name.
|
|
890
|
+
*
|
|
891
|
+
* A name carries its adapter in front of the first double underscore, and carries a tab suffix only
|
|
892
|
+
* when two tabs offer the same tool — see `docs/tool_naming_and_tab_identity.md`.
|
|
893
|
+
*
|
|
894
|
+
* The browser's own tools are left out. They are answered by the bridge rather than by any page, they
|
|
895
|
+
* are always there, and counting them as an adapter would report a page open when none is.
|
|
896
|
+
*
|
|
897
|
+
* @param toolNames - The names the host offered.
|
|
898
|
+
* @returns One entry per site adapter, by site slug, in alphabetical order.
|
|
899
|
+
*/
|
|
900
|
+
static _groupByAdapter(toolNames) {
|
|
901
|
+
const bySlug = /* @__PURE__ */ new Map();
|
|
902
|
+
for (const name of toolNames) {
|
|
903
|
+
if (ToolNaming.belongsTo(name, ToolNaming.BROWSER_SLUG) === true) {
|
|
904
|
+
continue;
|
|
905
|
+
}
|
|
906
|
+
const parts = ToolNaming.unqualify(name);
|
|
907
|
+
const siteSlug = parts === null ? name : parts.siteSlug;
|
|
908
|
+
const adapter = bySlug.get(siteSlug) ?? {
|
|
909
|
+
siteSlug,
|
|
910
|
+
toolCount: 0,
|
|
911
|
+
tabIds: []
|
|
912
|
+
};
|
|
913
|
+
adapter.toolCount = adapter.toolCount + 1;
|
|
914
|
+
const tab = /__tab(\d+)$/.exec(name);
|
|
915
|
+
if (tab !== null) {
|
|
916
|
+
const tabId = Number.parseInt(tab[1], 10);
|
|
917
|
+
if (adapter.tabIds.includes(tabId) === false) {
|
|
918
|
+
adapter.tabIds.push(tabId);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
bySlug.set(siteSlug, adapter);
|
|
922
|
+
}
|
|
923
|
+
const adapters = [...bySlug.values()];
|
|
924
|
+
for (const adapter of adapters) {
|
|
925
|
+
adapter.tabIds.sort((left, right) => left - right);
|
|
926
|
+
}
|
|
927
|
+
adapters.sort((left, right) => left.siteSlug.localeCompare(right.siteSlug));
|
|
928
|
+
return adapters;
|
|
929
|
+
}
|
|
930
|
+
/**
|
|
931
|
+
* The two steps only a person can take, named with the folder to pick.
|
|
932
|
+
*
|
|
933
|
+
* @param extensionDir - The folder to load at `chrome://extensions`.
|
|
934
|
+
* @returns The lines to print.
|
|
935
|
+
*/
|
|
936
|
+
static _loadTheExtension(extensionDir) {
|
|
937
|
+
return [
|
|
938
|
+
"Chrome loads an unpacked extension by hand:",
|
|
939
|
+
" 1. Open chrome://extensions and turn on Developer mode.",
|
|
940
|
+
" 2. Choose Load unpacked, and select this folder:",
|
|
941
|
+
` ${extensionDir}`
|
|
942
|
+
];
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
|
|
946
|
+
// tools/npm_command_entry.ts
|
|
947
|
+
var __filename3 = import.meta.filename;
|
|
948
|
+
var __dirname3 = import.meta.dirname;
|
|
949
|
+
var NpmCommandEntry = class _NpmCommandEntry {
|
|
950
|
+
static {
|
|
951
|
+
/** The path segments that name a folder npm fills for one run and empties whenever it decides to. */
|
|
952
|
+
this.CACHE_SEGMENTS = ["_npx", "_cacache"];
|
|
953
|
+
}
|
|
954
|
+
static {
|
|
955
|
+
/** The issue holding the plan this command is a milestone of. */
|
|
956
|
+
this.PLAN_URL = "https://github.com/jeromeetienne/webmcp_everywhere/issues/12";
|
|
957
|
+
}
|
|
958
|
+
static {
|
|
959
|
+
/** Where an agent is pointed once the extension is loaded and Chrome has started the host. */
|
|
960
|
+
this.ENDPOINT_URL = "http://127.0.0.1:8765/mcp";
|
|
961
|
+
}
|
|
962
|
+
/**
|
|
963
|
+
* Runs the subcommand the arguments name.
|
|
964
|
+
*
|
|
965
|
+
* @param argv - The arguments after the command name, which is `process.argv.slice(2)`.
|
|
966
|
+
* @returns Nothing.
|
|
967
|
+
*/
|
|
968
|
+
static async run(argv) {
|
|
969
|
+
const subcommand = argv[0] ?? "install";
|
|
970
|
+
if (subcommand === "install") {
|
|
971
|
+
_NpmCommandEntry._install();
|
|
972
|
+
await _NpmCommandEntry._reportStatus(true);
|
|
973
|
+
return;
|
|
974
|
+
}
|
|
975
|
+
if (subcommand === "status") {
|
|
976
|
+
const report = await _NpmCommandEntry._reportStatus(false);
|
|
977
|
+
process.exitCode = report.isReady === true ? 0 : 1;
|
|
978
|
+
return;
|
|
979
|
+
}
|
|
980
|
+
if (subcommand === "uninstall") {
|
|
981
|
+
_NpmCommandEntry._uninstall();
|
|
982
|
+
return;
|
|
983
|
+
}
|
|
984
|
+
if (subcommand === "--version" || subcommand === "version") {
|
|
985
|
+
console.log(_NpmCommandEntry._extensionVersion());
|
|
986
|
+
return;
|
|
987
|
+
}
|
|
988
|
+
if (subcommand === "--help" || subcommand === "help") {
|
|
989
|
+
_NpmCommandEntry._usage(console.log);
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
console.error(`webmcp_everywhere: no subcommand called ${subcommand}`);
|
|
993
|
+
_NpmCommandEntry._usage(console.error);
|
|
994
|
+
process.exitCode = 1;
|
|
995
|
+
}
|
|
996
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
997
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
998
|
+
// Helpers
|
|
999
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1000
|
+
///////////////////////////////////////////////////////////////////////////////
|
|
1001
|
+
/**
|
|
1002
|
+
* Names every path the installation is about to write, then writes them.
|
|
1003
|
+
*
|
|
1004
|
+
* The announcement comes first for the same reason the installer inside a release announces: from
|
|
1005
|
+
* the moment the host manifest exists, Chrome starts a program outside the browser sandbox with the
|
|
1006
|
+
* user's full rights, and that is not a thing to be opted into silently.
|
|
1007
|
+
*
|
|
1008
|
+
* @returns Nothing.
|
|
1009
|
+
*/
|
|
1010
|
+
static _install() {
|
|
1011
|
+
const planned = PackagedReleaseInstallation.plan({
|
|
1012
|
+
sourceDir: __dirname3
|
|
1013
|
+
});
|
|
1014
|
+
console.log(`WebMCP Everywhere ${_NpmCommandEntry._extensionVersion()}`);
|
|
1015
|
+
console.log("");
|
|
1016
|
+
if (planned.isAlreadyInPlace === true) {
|
|
1017
|
+
console.log(`This is already installed at ${planned.targetDir}, so only the registration runs again.`);
|
|
1018
|
+
} else {
|
|
1019
|
+
const verb = planned.isTargetDirReplaced === true ? "replaces the folder" : "writes the folder";
|
|
1020
|
+
console.log(`This ${verb} ${planned.targetDir}`);
|
|
1021
|
+
console.log(`with a copy of ${planned.sourceDir}`);
|
|
1022
|
+
if (_NpmCommandEntry._isFolderNpmMayEmpty(planned.sourceDir) === true) {
|
|
1023
|
+
console.log("");
|
|
1024
|
+
console.log("The copy is made because npm empties the folder above whenever it decides to, and");
|
|
1025
|
+
console.log("Chrome keeps an absolute path for both an unpacked extension and a native messaging");
|
|
1026
|
+
console.log("host. Registering the folder npm owns would break the day npm cleared it.");
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
console.log("");
|
|
1030
|
+
console.log("It is also about to write:");
|
|
1031
|
+
for (const manifestPath of planned.nativeHost.manifests) {
|
|
1032
|
+
console.log(` ${manifestPath}`);
|
|
1033
|
+
}
|
|
1034
|
+
console.log("");
|
|
1035
|
+
console.log(`Each of those tells Chrome to start: ${Path6.join(planned.targetDir, ReleaseLayout.LAUNCHER)}`);
|
|
1036
|
+
console.log(`and to let only the extension ${planned.nativeHost.identifier} talk to it.`);
|
|
1037
|
+
console.log("");
|
|
1038
|
+
console.log("Chrome will start that program outside the browser sandbox, with your rights.");
|
|
1039
|
+
console.log("To undo all of it: npx webmcp_everywhere uninstall");
|
|
1040
|
+
console.log("");
|
|
1041
|
+
const installed = PackagedReleaseInstallation.install({
|
|
1042
|
+
sourceDir: __dirname3
|
|
1043
|
+
});
|
|
1044
|
+
console.log(`installed ${installed.targetDir}`);
|
|
1045
|
+
for (const manifestPath of installed.nativeHost.manifests) {
|
|
1046
|
+
console.log(`wrote ${manifestPath}`);
|
|
1047
|
+
}
|
|
1048
|
+
}
|
|
1049
|
+
/**
|
|
1050
|
+
* Asks the running system whether the extension is loaded and reaching an agent, and says so.
|
|
1051
|
+
*
|
|
1052
|
+
* @param isAfterInstall - Whether this follows an installation, which changes what is left to say.
|
|
1053
|
+
* @returns What the check found, so a caller can act on it.
|
|
1054
|
+
*/
|
|
1055
|
+
static async _reportStatus(isAfterInstall) {
|
|
1056
|
+
const report = await InstallationStatus.read();
|
|
1057
|
+
console.log("");
|
|
1058
|
+
console.log(report.summary);
|
|
1059
|
+
if (report.remedy.length > 0) {
|
|
1060
|
+
console.log("");
|
|
1061
|
+
for (const line of report.remedy) {
|
|
1062
|
+
console.log(line);
|
|
1063
|
+
}
|
|
1064
|
+
}
|
|
1065
|
+
if (report.adapters.length > 0) {
|
|
1066
|
+
console.log("");
|
|
1067
|
+
const widest = Math.max(...report.adapters.map((adapter) => adapter.siteSlug.length));
|
|
1068
|
+
for (const adapter of report.adapters) {
|
|
1069
|
+
const tabs = adapter.tabIds.length === 0 ? "" : ` in ${adapter.tabIds.length === 1 ? "tab" : "tabs"} ${adapter.tabIds.join(", ")}`;
|
|
1070
|
+
const count = `${adapter.toolCount}`.padStart(2);
|
|
1071
|
+
console.log(` ${adapter.siteSlug.padEnd(widest)} ${count} tools${tabs}`);
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
const tokenPath = Path6.join(report.stateDir, "token");
|
|
1075
|
+
if (report.isReady === true && report.endpoint !== null) {
|
|
1076
|
+
console.log("");
|
|
1077
|
+
console.log(`Point your agent at ${report.endpoint.url}, with the bearer token from`);
|
|
1078
|
+
console.log(` ${tokenPath}`);
|
|
1079
|
+
} else if (isAfterInstall === true) {
|
|
1080
|
+
console.log("");
|
|
1081
|
+
console.log(`Once it is loaded, point your agent at ${_NpmCommandEntry.ENDPOINT_URL}, with the`);
|
|
1082
|
+
console.log(`bearer token from ${tokenPath}`);
|
|
1083
|
+
}
|
|
1084
|
+
return report;
|
|
1085
|
+
}
|
|
1086
|
+
/**
|
|
1087
|
+
* Removes the registration and the installation folder, and says what it left behind.
|
|
1088
|
+
*
|
|
1089
|
+
* @returns Nothing.
|
|
1090
|
+
*/
|
|
1091
|
+
static _uninstall() {
|
|
1092
|
+
const removed = PackagedReleaseInstallation.remove({
|
|
1093
|
+
sourceDir: __dirname3
|
|
1094
|
+
});
|
|
1095
|
+
for (const manifest of removed.manifests) {
|
|
1096
|
+
if (manifest.isRemoved === true) {
|
|
1097
|
+
console.log(`removed ${manifest.path}`);
|
|
1098
|
+
if (manifest.launcher !== null) {
|
|
1099
|
+
console.log(` it told Chrome to start: ${manifest.launcher}`);
|
|
1100
|
+
}
|
|
1101
|
+
} else {
|
|
1102
|
+
console.log(`nothing to remove at ${manifest.path}`);
|
|
1103
|
+
}
|
|
1104
|
+
}
|
|
1105
|
+
if (removed.isTargetDirRemoved === true) {
|
|
1106
|
+
console.log(`removed ${removed.targetDir}`);
|
|
1107
|
+
} else {
|
|
1108
|
+
console.log(`nothing to remove at ${removed.targetDir}`);
|
|
1109
|
+
}
|
|
1110
|
+
console.log("");
|
|
1111
|
+
console.log("Chrome will no longer start the native messaging host for this extension.");
|
|
1112
|
+
console.log("The extension itself is removed at chrome://extensions, which this does not touch.");
|
|
1113
|
+
console.log(`Your bearer token and your loaded adapters are left alone in ${removed.stateDir}`);
|
|
1114
|
+
console.log(`To remove those as well: rm -rf ${removed.stateDir}`);
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* Prints what the command accepts.
|
|
1118
|
+
*
|
|
1119
|
+
* @param write - Where the lines go, so that an unknown subcommand can print this to standard error.
|
|
1120
|
+
* @returns Nothing.
|
|
1121
|
+
*/
|
|
1122
|
+
static _usage(write) {
|
|
1123
|
+
write("");
|
|
1124
|
+
write(" npx webmcp_everywhere install it, then say whether it is working");
|
|
1125
|
+
write(" npx webmcp_everywhere status say whether it is working, and exit 1 when it is not");
|
|
1126
|
+
write(" npx webmcp_everywhere uninstall take the installation and the registration back out");
|
|
1127
|
+
write(" npx webmcp_everywhere --version print the version of the extension it carries");
|
|
1128
|
+
write("");
|
|
1129
|
+
write(`The plan this command follows is ${_NpmCommandEntry.PLAN_URL}`);
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Answers whether npm empties the folder a path names.
|
|
1133
|
+
*
|
|
1134
|
+
* @param folder - The folder to test.
|
|
1135
|
+
* @returns True when a segment of the path names one of npm's own caches.
|
|
1136
|
+
*/
|
|
1137
|
+
static _isFolderNpmMayEmpty(folder) {
|
|
1138
|
+
const segments = folder.split(Path6.sep);
|
|
1139
|
+
return _NpmCommandEntry.CACHE_SEGMENTS.some((segment) => segments.includes(segment));
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Reads the version out of the extension manifest this package carries.
|
|
1143
|
+
*
|
|
1144
|
+
* The packaged release holds a manifest of its own that npm publishes it with, and the version there
|
|
1145
|
+
* and the version here are the same number; `tools/package_release.ts` refuses to package them apart.
|
|
1146
|
+
* This one is read because it is the version of the thing a person loads into Chrome.
|
|
1147
|
+
*
|
|
1148
|
+
* @returns The version string, or `unknown` when the manifest is missing or unreadable.
|
|
1149
|
+
*/
|
|
1150
|
+
static _extensionVersion() {
|
|
1151
|
+
const manifestPath = Path6.join(__dirname3, ReleaseLayout.EXTENSION_DIR, ReleaseLayout.EXTENSION_MANIFEST);
|
|
1152
|
+
if (Fs7.existsSync(manifestPath) === false) {
|
|
1153
|
+
return "unknown";
|
|
1154
|
+
}
|
|
1155
|
+
try {
|
|
1156
|
+
const manifest = JSON.parse(Fs7.readFileSync(manifestPath, "utf8"));
|
|
1157
|
+
return manifest.version ?? "unknown";
|
|
1158
|
+
} catch {
|
|
1159
|
+
return "unknown";
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
await NpmCommandEntry.run(process.argv.slice(2));
|
|
1164
|
+
export {
|
|
1165
|
+
NpmCommandEntry
|
|
1166
|
+
};
|