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.
@@ -0,0 +1,327 @@
1
+ // tools/release_installer_entry.ts
2
+ import Path3 from "node:path";
3
+
4
+ // tools/install_native_host.ts
5
+ import Fs2 from "node:fs";
6
+ import Os from "node:os";
7
+ import Path2 from "node:path";
8
+
9
+ // tools/generate_extension_key.ts
10
+ import Crypto from "node:crypto";
11
+ import Fs from "node:fs";
12
+ import Path from "node:path";
13
+ var __filename = import.meta.filename;
14
+ var __dirname = import.meta.dirname;
15
+ var GenerateExtensionKey = class _GenerateExtensionKey {
16
+ /**
17
+ * Derives Chrome's extension identifier from a public key.
18
+ *
19
+ * Chrome takes the SHA-256 of the DER-encoded public key, keeps the first sixteen bytes, and maps
20
+ * each of the thirty-two nibbles onto the letters `a` to `p`.
21
+ *
22
+ * @param publicKeyDer - The DER-encoded SubjectPublicKeyInfo.
23
+ * @returns The thirty-two character extension identifier.
24
+ */
25
+ static identifierFromPublicKey(publicKeyDer) {
26
+ const digest = Crypto.createHash("sha256").update(publicKeyDer).digest();
27
+ let identifier = "";
28
+ for (const byte of digest.subarray(0, 16)) {
29
+ identifier += String.fromCharCode(97 + (byte >> 4));
30
+ identifier += String.fromCharCode(97 + (byte & 15));
31
+ }
32
+ return identifier;
33
+ }
34
+ /**
35
+ * Generates a key pair, writes the public half into the manifest, and reports the identifier.
36
+ *
37
+ * @returns What was generated and where the private half went.
38
+ */
39
+ static run() {
40
+ const manifestPath = Path.join(__dirname, "..", "src", "chrome_extension", "manifest.json");
41
+ const manifest = JSON.parse(Fs.readFileSync(manifestPath, "utf8"));
42
+ if (manifest.key !== void 0) {
43
+ const identifier = _GenerateExtensionKey.identifierFromPublicKey(
44
+ Buffer.from(manifest.key, "base64")
45
+ );
46
+ return {
47
+ identifier,
48
+ privateKeyPath: "unchanged, the manifest already carries a key"
49
+ };
50
+ }
51
+ const { publicKey, privateKey } = Crypto.generateKeyPairSync("rsa", {
52
+ modulusLength: 2048
53
+ });
54
+ const publicKeyDer = publicKey.export({
55
+ type: "spki",
56
+ format: "der"
57
+ });
58
+ manifest.key = publicKeyDer.toString("base64");
59
+ Fs.writeFileSync(manifestPath, JSON.stringify(manifest, null, " ") + "\n");
60
+ const privateKeyPath = Path.join(__dirname, "..", "extension_private_key.pem");
61
+ Fs.writeFileSync(
62
+ privateKeyPath,
63
+ privateKey.export({
64
+ type: "pkcs8",
65
+ format: "pem"
66
+ })
67
+ );
68
+ return {
69
+ identifier: _GenerateExtensionKey.identifierFromPublicKey(publicKeyDer),
70
+ privateKeyPath
71
+ };
72
+ }
73
+ /**
74
+ * Reads the extension identifier the manifest currently pins.
75
+ *
76
+ * @returns The extension identifier.
77
+ * @throws When the manifest carries no key.
78
+ */
79
+ static currentIdentifier(manifestPath) {
80
+ const readFrom = manifestPath ?? Path.join(__dirname, "..", "src", "chrome_extension", "manifest.json");
81
+ const manifest = JSON.parse(Fs.readFileSync(readFrom, "utf8"));
82
+ if (manifest.key === void 0) {
83
+ throw new Error('the manifest has no key; run "node tools/generate_extension_key_entry.ts" first');
84
+ }
85
+ return _GenerateExtensionKey.identifierFromPublicKey(Buffer.from(manifest.key, "base64"));
86
+ }
87
+ };
88
+
89
+ // tools/install_native_host.ts
90
+ var __filename2 = import.meta.filename;
91
+ var __dirname2 = import.meta.dirname;
92
+ var InstallNativeHost = class _InstallNativeHost {
93
+ static {
94
+ /** The host name the extension asks for, which must match the manifest file name. */
95
+ this.HOST_NAME = "com.webmcp_everywhere.host";
96
+ }
97
+ /**
98
+ * Works out what an installation would write, without writing any of it.
99
+ *
100
+ * This exists so that the installation can say what it is about to do to the user's machine before it
101
+ * does it. Writing a file into a browser the user installed is not something to announce afterwards.
102
+ *
103
+ * @param options - Where the installation would write.
104
+ * @returns The files the installation would write.
105
+ */
106
+ static plan(options = {}) {
107
+ const identifier = GenerateExtensionKey.currentIdentifier(options.extensionManifestPath);
108
+ const launcher = _InstallNativeHost._resolveLauncher(options.launcherPath);
109
+ return {
110
+ identifier,
111
+ launcher,
112
+ manifests: _InstallNativeHost.manifestPaths(options)
113
+ };
114
+ }
115
+ /**
116
+ * Names every manifest file the installation writes, without reading anything else.
117
+ *
118
+ * The installation, the uninstallation and the command that copies a release somewhere stable all
119
+ * need this same list, and a file name spelled in three places is spelled wrong in one of them
120
+ * eventually. It reads no launcher and no extension manifest, so it also answers before an
121
+ * installation exists, which is what lets a command name the files it is about to write.
122
+ *
123
+ * @param options - Which directories to cover.
124
+ * @returns The manifest files, in the order they are written.
125
+ */
126
+ static manifestPaths(options = {}) {
127
+ return _InstallNativeHost.manifestDirectories(options).map((directory) => {
128
+ return Path2.join(directory, `${_InstallNativeHost.HOST_NAME}.json`);
129
+ });
130
+ }
131
+ /**
132
+ * Writes the launcher and the manifest.
133
+ *
134
+ * @param options - Where to install.
135
+ * @returns What was written.
136
+ */
137
+ static run(options = {}) {
138
+ const planned = _InstallNativeHost.plan(options);
139
+ const manifest = _InstallNativeHost._renderManifest(
140
+ planned.launcher,
141
+ planned.identifier,
142
+ options.templateDir
143
+ );
144
+ for (const manifestPath of planned.manifests) {
145
+ Fs2.mkdirSync(Path2.dirname(manifestPath), {
146
+ recursive: true
147
+ });
148
+ Fs2.writeFileSync(manifestPath, manifest);
149
+ }
150
+ return planned;
151
+ }
152
+ /**
153
+ * Lists every directory Chrome might read host manifests from.
154
+ *
155
+ * The everyday Chrome reads them from its own support directory. A Chrome started with a custom
156
+ * `--user-data-dir`, which is what `LaunchChrome` and the verification runners use, reads them from
157
+ * inside that directory instead and never looks at the everyday one, which is why a throwaway profile
158
+ * can be covered without touching the browser the user installed.
159
+ *
160
+ * This is public because the uninstallation has to remove a manifest from exactly the directories the
161
+ * installation writes one into, and two lists that have to agree are one list.
162
+ *
163
+ * @param options - Which directories to cover.
164
+ * @returns The directories that hold a manifest, the everyday Chrome first when it is covered.
165
+ */
166
+ static manifestDirectories(options = {}) {
167
+ const directories = [];
168
+ if (options.isEverydayChromeCovered !== false) {
169
+ directories.push(_InstallNativeHost.everydayChromeDirectory());
170
+ }
171
+ for (const userDataDir of options.userDataDirs ?? []) {
172
+ directories.push(Path2.join(userDataDir, "NativeMessagingHosts"));
173
+ }
174
+ return directories;
175
+ }
176
+ /**
177
+ * Names the directory the everyday Chrome, the one the user installed, reads host manifests from.
178
+ *
179
+ * @param homeDir - The home folder to read it out of, for a runner installing into a throwaway one.
180
+ * @returns The absolute path of that directory on this platform.
181
+ */
182
+ static everydayChromeDirectory(homeDir = Os.homedir()) {
183
+ if (process.platform === "darwin") {
184
+ return Path2.join(homeDir, "Library", "Application Support", "Google", "Chrome", "NativeMessagingHosts");
185
+ }
186
+ return Path2.join(homeDir, ".config", "google-chrome", "NativeMessagingHosts");
187
+ }
188
+ ///////////////////////////////////////////////////////////////////////////////
189
+ ///////////////////////////////////////////////////////////////////////////////
190
+ // Helpers
191
+ ///////////////////////////////////////////////////////////////////////////////
192
+ ///////////////////////////////////////////////////////////////////////////////
193
+ /**
194
+ * Fills the host manifest template in with this installation's values.
195
+ *
196
+ * The manifest lives in `data/native_messaging_template/com.webmcp_everywhere.host.json` rather than in
197
+ * this file, so that the shape Chrome reads can be looked at and edited as the JSON document it is. It
198
+ * is read every time instead of being cached, because an installation runs once and then exits. Every
199
+ * placeholder has to be replaced, so an unreplaced one is an error rather than something written out
200
+ * to Chrome, which would refuse the manifest with no useful message.
201
+ *
202
+ * @param launcher - The absolute path to the executable file Chrome starts.
203
+ * @param identifier - The extension identifier the manifest allows to connect.
204
+ * @param templateDir - The folder holding the template, or nothing for this working copy's.
205
+ * @returns The manifest text to write, ending in a newline.
206
+ */
207
+ static _renderManifest(launcher, identifier, templateDir) {
208
+ const folder = templateDir ?? Path2.join(__dirname2, "..", "data", "native_messaging_template");
209
+ const templatePath = Path2.join(folder, `${_InstallNativeHost.HOST_NAME}.json`);
210
+ if (Fs2.existsSync(templatePath) === false) {
211
+ throw new Error(`host manifest template is missing: ${templatePath}`);
212
+ }
213
+ const template = Fs2.readFileSync(templatePath, "utf8");
214
+ const values = {
215
+ hostName: _InstallNativeHost.HOST_NAME,
216
+ launcherPath: launcher,
217
+ extensionIdentifier: identifier
218
+ };
219
+ let rendered = template;
220
+ for (const [placeholder, value] of Object.entries(values)) {
221
+ rendered = rendered.split(`{{${placeholder}}}`).join(value);
222
+ }
223
+ const leftover = rendered.match(/\{\{[^}]*\}\}/);
224
+ if (leftover !== null) {
225
+ throw new Error(`host manifest template has an unknown placeholder: ${leftover[0]}`);
226
+ }
227
+ JSON.parse(rendered);
228
+ return rendered.endsWith("\n") === true ? rendered : rendered + "\n";
229
+ }
230
+ /**
231
+ * Locates the executable Chrome actually launches.
232
+ *
233
+ * Chrome runs the path in the manifest directly, so it has to be an executable file rather than a
234
+ * script it would have to know how to interpret. `bin/webmcp_native_host.sh` is that file, it is
235
+ * kept in the repository, and it works out the rest of the paths on its own, so this only has to
236
+ * check that it is there and that it is executable.
237
+ *
238
+ * @param named - A launcher to use instead of this working copy's, such as a packaged release's.
239
+ * @returns The absolute path to the launcher.
240
+ * @throws When the launcher is not there.
241
+ */
242
+ static _resolveLauncher(named) {
243
+ const repoRoot = Path2.join(__dirname2, "..");
244
+ const launcher = named === void 0 ? Path2.join(repoRoot, "bin", "webmcp_native_host.sh") : Path2.resolve(named);
245
+ if (Fs2.existsSync(launcher) === false) {
246
+ throw new Error(`launcher is missing: ${launcher}`);
247
+ }
248
+ Fs2.chmodSync(launcher, 493);
249
+ return launcher;
250
+ }
251
+ };
252
+
253
+ // tools/release_layout.ts
254
+ var ReleaseLayout = class {
255
+ static {
256
+ /** The bundled native messaging host, one file with its dependencies inlined. */
257
+ this.HOST_BUNDLE = "webmcp_native_host.mjs";
258
+ }
259
+ static {
260
+ /** The launcher Chrome starts, which finds a Node.js and runs the bundle beside it. */
261
+ this.LAUNCHER = "webmcp_native_host.sh";
262
+ }
263
+ static {
264
+ /** The installer that registers that launcher with Chrome. */
265
+ this.INSTALLER = "install_the_native_messaging_host.mjs";
266
+ }
267
+ static {
268
+ /** The command an `npx webmcp_everywhere` run starts, which the `bin` field of the manifest names. */
269
+ this.COMMAND = "webmcp_everywhere.mjs";
270
+ }
271
+ static {
272
+ /** The manifest npm publishes the folder with. */
273
+ this.PACKAGE_MANIFEST = "package.json";
274
+ }
275
+ static {
276
+ /** The folder a person loads at `chrome://extensions`. */
277
+ this.EXTENSION_DIR = "chrome_extension";
278
+ }
279
+ static {
280
+ /** The folder holding the host manifest template the installer fills in. */
281
+ this.TEMPLATE_DIR = "native_messaging_template";
282
+ }
283
+ static {
284
+ /** The extension manifest inside the extension folder, which pins the extension identifier. */
285
+ this.EXTENSION_MANIFEST = "manifest.json";
286
+ }
287
+ };
288
+
289
+ // tools/release_installer_entry.ts
290
+ var __filename3 = import.meta.filename;
291
+ var __dirname3 = import.meta.dirname;
292
+ var ReleaseInstallerEntry = class {
293
+ /**
294
+ * Announces every file, then writes them.
295
+ *
296
+ * @returns Nothing.
297
+ */
298
+ static run() {
299
+ const options = {
300
+ launcherPath: Path3.join(__dirname3, ReleaseLayout.LAUNCHER),
301
+ templateDir: Path3.join(__dirname3, ReleaseLayout.TEMPLATE_DIR),
302
+ extensionManifestPath: Path3.join(__dirname3, ReleaseLayout.EXTENSION_DIR, ReleaseLayout.EXTENSION_MANIFEST)
303
+ };
304
+ const planned = InstallNativeHost.plan(options);
305
+ console.log("This registers WebMCP Everywhere with Google Chrome. It is about to write:");
306
+ for (const manifestPath of planned.manifests) {
307
+ console.log(` ${manifestPath}`);
308
+ }
309
+ console.log("");
310
+ console.log(`Each of those tells Chrome to start: ${planned.launcher}`);
311
+ console.log(`and to let only the extension ${planned.identifier} talk to it.`);
312
+ console.log("");
313
+ console.log("Chrome will start that program outside the browser sandbox, with your rights.");
314
+ console.log("To undo this, delete the files listed above.");
315
+ console.log("");
316
+ const written = InstallNativeHost.run(options);
317
+ for (const manifestPath of written.manifests) {
318
+ console.log(`wrote ${manifestPath}`);
319
+ }
320
+ console.log("");
321
+ console.log(`Now load the ${ReleaseLayout.EXTENSION_DIR} folder at chrome://extensions, with Developer mode on.`);
322
+ }
323
+ };
324
+ ReleaseInstallerEntry.run();
325
+ export {
326
+ ReleaseInstallerEntry
327
+ };
@@ -0,0 +1,16 @@
1
+ # Directory Context: `/data/native_messaging_template`
2
+
3
+ ## Purpose
4
+ Holds the template for the Chrome native messaging host manifest, the JSON file that tells Chrome which program to start and which extension may connect to it.
5
+
6
+ ## Key Exports & Entry Points
7
+ - `com.webmcp_everywhere.host.json`: the template. `tools/install_native_host.ts` reads it, replaces the placeholders, and writes the result into every Chrome native messaging host directory.
8
+ - Command to write the manifests: `npm run install:host`
9
+
10
+ ## Rules
11
+ - The placeholders are `{{hostName}}`, `{{launcherPath}}`, and `{{extensionIdentifier}}`. Adding a placeholder here without adding its value in `InstallNativeHost._renderManifest` fails the installation, on purpose.
12
+ - Never write the host name, the launcher path, or the extension identifier here as a literal value. Each of those has one authoritative place in the TypeScript, and a second copy here would disagree with it.
13
+ - The field names are Chrome's, not this project's: `name`, `description`, `path`, `type`, and `allowed_origins`. Chrome refuses a manifest with any other spelling and reports nothing useful when it does.
14
+
15
+ ## Background
16
+ - Chrome's native messaging documentation defines the manifest and the directories it is read from — see [issue #2](https://github.com/jeromeetienne/webmcp_everywhere/issues/2).
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "{{hostName}}",
3
+ "description": "WebMCP Everywhere — serves the extension tools over Model Context Protocol",
4
+ "path": "{{launcherPath}}",
5
+ "type": "stdio",
6
+ "allowed_origins": [
7
+ "chrome-extension://{{extensionIdentifier}}/"
8
+ ]
9
+ }
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "webmcp_everywhere",
3
+ "version": "0.1.0",
4
+ "license": "MIT",
5
+ "description": "A community-maintained library of WebMCP adapters that register tools into sites that have not shipped their own.",
6
+ "keywords": [
7
+ "webmcp",
8
+ "model context protocol",
9
+ "chrome extension",
10
+ "browser agent",
11
+ "site adapter"
12
+ ],
13
+ "homepage": "https://github.com/jeromeetienne/webmcp_everywhere#readme",
14
+ "repository": {
15
+ "type": "git",
16
+ "url": "git+https://github.com/jeromeetienne/webmcp_everywhere.git"
17
+ },
18
+ "bugs": {
19
+ "url": "https://github.com/jeromeetienne/webmcp_everywhere/issues"
20
+ },
21
+ "type": "module",
22
+ "engines": {
23
+ "node": ">=20"
24
+ },
25
+ "bin": {
26
+ "webmcp_everywhere": "./webmcp_everywhere.mjs"
27
+ }
28
+ }