@twinsearth/w2m-dsh-plugin 0.54.0 → 0.55.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/CHANGELOG.md CHANGED
@@ -4,6 +4,60 @@ All notable changes to this project are documented here. The format follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project
5
5
  adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
+ ## [0.55.0] — 2026-10-15
8
+
9
+ **A second transport binding — and `mcp.json` now declares both.**
10
+
11
+ ### The same protocol, two bindings, and no logic shared between them
12
+
13
+ The MCP overview states it plainly: **"protocol semantics are identical on every transport. A transport is a
14
+ binding."** So `src/mcp/http-transport.mjs` contains **no protocol decisions** — it decodes a request, hands the
15
+ body to the same `handleMessage` the stdio binding uses, and encodes the reply. **Neither binding imports the
16
+ other.** The single command reaches the fleet over both.
17
+
18
+ ### What revision `2026-07-28` removed, which made this smaller than expected
19
+
20
+ * **The GET stream endpoint is gone.** A bare `GET /mcp` gets **405 naming the revision**, not a 404 — a client
21
+ written against the older revision should learn *why* rather than guess at the URL.
22
+ * **Protocol-level sessions are gone.** No `Mcp-Session-Id`, no session table, no state between requests. Two
23
+ identical requests return identical bytes, and the test asserts it.
24
+
25
+ `GET /mcp?discover` still answers, **gated on an explicit parameter**: a bare GET is what the removed stream
26
+ client sends, and handing it a description document to parse as a stream would be worse than refusing it.
27
+
28
+ ### The `Origin` rule is a MUST, and it is easy to skip
29
+
30
+ > **"Servers MUST validate the `Origin` header on all incoming connections to prevent DNS rebinding attacks."**
31
+
32
+ A loopback MCP server is reachable by **any page the user's browser loads**, and a rebinding attack makes that
33
+ page same-origin. So a refused `Origin` gets **403 before the body is read** — asserted with an unparseable body,
34
+ because ordering is the property.
35
+
36
+ **A missing `Origin` is allowed, and that is a decision rather than an oversight**: the header constrains
37
+ *browsers*, an attacker's browser always sends one, and refusing an absent header would break every
38
+ command-line client while stopping no attack. The default bind is **127.0.0.1**, per the specification's SHOULD.
39
+
40
+ ### `mcp.json` declares both, and one of them is not a subprocess
41
+
42
+ ```json
43
+ "w2m-fleet": { "type": "stdio", "command": "node", "args": ["./bin/w2m-mcp.mjs"], "cwd": "${PLUGIN_ROOT}" },
44
+ "w2m-fleet-http": { "type": "streamable-http", "url": "http://127.0.0.1:8766/mcp" }
45
+ ```
46
+
47
+ **The HTTP entry names a server that must be started separately** — `node bin/w2m-mcp.mjs --http` — because a
48
+ remote-transport entry has no `command` to launch. That is what the transport type means, and the alternative —
49
+ declaring only stdio — would leave an HTTP-only client with nothing to connect to. **The `cwd` is
50
+ `${PLUGIN_ROOT}` rather than a path, so the entry works wherever the package is installed**, and
51
+ `plugin-manifest-check.mjs` validates both entries against the schema's closed per-transport field sets.
52
+
53
+ ### One assertion I wrote wrong, and the fix was the implementation
54
+
55
+ My discovery handler matched `GET /mcp` **exactly**, while the test sent `GET /mcp?discover=1`. **The test was
56
+ right and the code was wrong** — the two GETs are different callers with different needs, and telling them apart
57
+ by a parameter is honest where guessing at intent is not. Fixed the handler, not the test.
58
+
59
+ **16 new tests, 0 failures.** Full suite 2318 in 86 files. **Pre-flight GREEN with FIFTEEN checks.**
60
+
7
61
  ## [0.54.0] — 2026-10-15
8
62
 
9
63
  **The fleet is published as an MCP server — read-only, and every declared route is checked against the relay's
package/bin/w2m-mcp.mjs CHANGED
@@ -337,10 +337,40 @@ export function serve({ input, output, deps }) {
337
337
  return pending;
338
338
  }
339
339
 
340
- function main() {
340
+ /**
341
+ * Start the server on the transport the caller asked for.
342
+ *
343
+ * **stdio is the default because it is what `mcp.json` declares and what a client launches.** `--http` is for
344
+ * the clients that require an HTTP endpoint -- one of them is known to insist on a local HTTP server with a
345
+ * discovery file rather than a subprocess -- and it is an explicit flag rather than a guess, because the two
346
+ * transports have different threat models: a subprocess talks to its parent, a socket talks to whatever can
347
+ * reach the port.
348
+ */
349
+ async function main() {
350
+ const argv = process.argv.slice(2);
351
+ const flag = (name, dflt) => (argv.includes(name) ? argv[argv.indexOf(name) + 1] : dflt);
341
352
  const config = readConfig();
342
353
  log(`starting; relay ${config.relayUrl}; device ${config.device?.machine_name ?? '(none)'}`);
343
354
  log(`read-only tools: ${TOOL_NAMES.join(', ')}`);
355
+
356
+ if (argv.includes('--http')) {
357
+ const { createMcpHttpServer } = await import('../src/mcp/http-transport.mjs');
358
+ const host = flag('--host', '127.0.0.1');
359
+ const port = Number(flag('--port', 8766));
360
+ if (!Number.isInteger(port) || port < 0 || port > 65535) {
361
+ log(`--port ${flag('--port')} is not a port number`);
362
+ process.exit(2);
363
+ }
364
+ const running = await createMcpHttpServer({ host, port, deps: { config, fetchImpl: fetch } });
365
+ // Printed so a launcher can read the port when it asked for 0, and on stderr so it never touches a protocol
366
+ // stream -- this binding has no stdout protocol, but the rule is the same one the stdio binding follows.
367
+ log(`streamable-http listening on ${running.url}`);
368
+ const stop = async () => { await running.close(); process.exit(0); };
369
+ process.on('SIGINT', stop);
370
+ process.on('SIGTERM', stop);
371
+ return;
372
+ }
373
+
344
374
  serve({ input: process.stdin, output: process.stdout, deps: { config, fetchImpl: fetch } });
345
375
  }
346
376
 
package/mcp.json CHANGED
@@ -6,6 +6,10 @@
6
6
  "command": "node",
7
7
  "args": ["./bin/w2m-mcp.mjs"],
8
8
  "cwd": "${PLUGIN_ROOT}"
9
+ },
10
+ "w2m-fleet-http": {
11
+ "type": "streamable-http",
12
+ "url": "http://127.0.0.1:8766/mcp"
9
13
  }
10
14
  }
11
15
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@twinsearth/w2m-dsh-plugin",
3
- "version": "0.54.0",
3
+ "version": "0.55.0",
4
4
  "description": "DSH: Windows2MacOS — drive the same project on online Windows and macOS machines from one DeepSeek account, across networks and regions. P2P hole punching is the default path (relay + shared server as rendezvous, ledger and fallback), with a bundled STUN responder, Tailscale/public-VPS/tunnel shapes, Rabbit relay + per-machine Localside + eight DSH tools (replicate / split), nine aggregate verdicts and signed requests.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -116,13 +116,13 @@
116
116
  }
117
117
  },
118
118
  "scripts": {
119
- "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/chatops-fleet-join.test.mjs",
119
+ "test": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-crypto.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/mcp-http.test.mjs test/chatops-fleet-join.test.mjs",
120
120
  "test:e2e": "node --test --test-force-exit test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/p2p-agent.test.mjs",
121
121
  "test:relay": "node --test --test-force-exit test/relay.test.mjs",
122
122
  "test:agent": "node --test --test-force-exit test/agent.test.mjs test/memory.test.mjs",
123
123
  "test:tools": "node --test --test-force-exit test/tools.test.mjs",
124
124
  "test:crossnetwork": "node --test --test-force-exit test/crossnetwork.test.mjs",
125
- "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/chatops-fleet-join.test.mjs",
125
+ "test:all": "node --test --test-force-exit test/relay.test.mjs test/broadcast.test.mjs test/agent.test.mjs test/plan-sec-cmd.test.mjs test/authority.test.mjs test/blocks.test.mjs test/approval-index.test.mjs test/fleet-sync.test.mjs test/health.test.mjs test/storage.test.mjs test/exit-channel.test.mjs test/council.test.mjs test/council-session.test.mjs test/log-bounds.test.mjs test/test-report.test.mjs test/fleet-scale.test.mjs test/fleet-state.test.mjs test/crypto-blake2s.test.mjs test/installed-version.test.mjs test/permission.test.mjs test/machine-test-packet.test.mjs test/field-authority.test.mjs test/approval-gate.test.mjs test/authority-choice.test.mjs test/reachability-check.test.mjs test/reachability-classify.test.mjs test/accepted-orphans.test.mjs test/acceptance-doc.test.mjs test/layer-model.test.mjs test/cwd-containment.test.mjs test/cwd-outside-project.test.mjs test/memory.test.mjs test/tools.test.mjs test/claims.test.mjs test/fleet-target.test.mjs test/client-bundle.test.mjs test/schedule.test.mjs test/auto-update.test.mjs test/update-source.test.mjs test/update-install.test.mjs test/update-wiring.test.mjs test/signing.test.mjs test/stress.test.mjs test/e2e.test.mjs test/crossnetwork.test.mjs test/signing-e2e.test.mjs test/outbound-only.test.mjs test/recovery.test.mjs test/pipeline-e2e.test.mjs test/shared-config.test.mjs test/metrics.test.mjs test/metrics-route.test.mjs test/tool-cards.test.mjs test/p2p-stun.test.mjs test/p2p-transport.test.mjs test/p2p-signaling.test.mjs test/p2p-node.test.mjs test/p2p-transport-fields.test.mjs test/p2p-plugin.test.mjs test/p2p-agent.test.mjs test/stun-server.test.mjs test/loader-entry.test.mjs test/mdns-wire.test.mjs test/mdns-discover.test.mjs test/p2p-crypto.test.mjs test/chatops-approval.test.mjs test/chatops-policy.test.mjs test/chatops-file-safety.test.mjs test/chatops-outbound-queue.test.mjs test/chatops-commands.test.mjs test/chatops-bridge.test.mjs test/chatops-config-route.test.mjs test/chatops-apply.test.mjs test/chatops-ilink-contract.test.mjs test/chatops-fleet.test.mjs test/chatops-account.test.mjs test/chatops-auth.test.mjs test/chatops-fleet-scope.test.mjs test/chatops-handoff.test.mjs test/chatops-fleet-wiring.test.mjs test/chatops-fleet-live.test.mjs test/chatops-takeover.test.mjs test/chatops-device-signing.test.mjs test/chatops-relay-verify.test.mjs test/chatops-durability.test.mjs test/chatops-run-command.test.mjs test/secrets-guard.test.mjs test/chatops-ilink-fleet-bridge.test.mjs test/agent-plugins.test.mjs test/memory-layer.test.mjs test/mcp-server.test.mjs test/mcp-http.test.mjs test/chatops-fleet-join.test.mjs",
126
126
  "pack": "node scripts/pack.mjs",
127
127
  "verify:join": "node scripts/verify-join.mjs",
128
128
  "lint": "eslint .",
package/plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "twinsearth-w2m-dsh",
4
- "version": "0.54.0",
4
+ "version": "0.55.0",
5
5
  "description": "Cross-platform fleet execution between Windows and macOS: one DeepSeek Harness account, a Rabbit relay, and signed device identity. Ships four Agent Skills describing fleet dispatch, field-level memory acceptance, session references, and resource scheduling.",
6
6
  "license": "MIT",
7
7
  "keywords": [
@@ -0,0 +1,216 @@
1
+ /**
2
+ * The Streamable HTTP binding for the MCP server (v0.55.0).
3
+ *
4
+ * ## Why this is a separate file from the stdio binding
5
+ *
6
+ * The MCP overview says it directly: **"Protocol semantics are identical on every transport. A transport is a
7
+ * binding"** -- it defines framing and delivery, never meaning. So this file contains no protocol decisions: it
8
+ * decodes a request, hands the body to `handleMessage`, and encodes the reply. **`bin/w2m-mcp.mjs` holds the
9
+ * semantics and neither binding imports the other.**
10
+ *
11
+ * ## What revision 2026-07-28 removed, which simplifies this by a lot
12
+ *
13
+ * * **The GET stream endpoint is gone.** A GET is refused rather than half-supported.
14
+ * * **Protocol-level sessions are gone.** No `Mcp-Session-Id`, no session table, no state between requests --
15
+ * which is what the overview means by "stateless, self-contained requests".
16
+ *
17
+ * ## The security requirement that is easy to skip
18
+ *
19
+ * **"Servers MUST validate the `Origin` header on all incoming connections to prevent DNS rebinding attacks."**
20
+ * A local MCP server bound to a loopback port is reachable by any page the user's browser loads, and a
21
+ * rebinding attack makes that page same-origin. So an invalid `Origin` gets **403**, and the default bind is
22
+ * **127.0.0.1** rather than every interface.
23
+ *
24
+ * ## Two response shapes, and why JSON is chosen
25
+ *
26
+ * The specification allows a plain JSON object or a request-scoped SSE stream. **SSE exists to interleave
27
+ * request-related notifications with the final response**, and this server emits no notifications during a call
28
+ * -- its only outbound messages are responses. So the JSON shape carries everything, and SSE would be a stream
29
+ * with one frame. `?stream=1` is honoured for a client that insists, because a client written against the
30
+ * stream form is entitled to it.
31
+ */
32
+ import { createServer } from 'node:http';
33
+ import { handleMessage, TOOL_NAMES, SUPPORTED_VERSIONS, SERVER_NAME, SERVER_VERSION } from '../../bin/w2m-mcp.mjs';
34
+
35
+ /** The path the MCP endpoint is mounted at. A single endpoint, per the specification. */
36
+ export const MCP_PATH = '/mcp';
37
+
38
+ /**
39
+ * Origins that may call this server.
40
+ *
41
+ * **Loopback only, and by exact match.** A rebinding attack arrives with an `Origin` the attacker chose, so the
42
+ * rule has to be an allow-list rather than a check for something suspicious -- and `null` (a `file://` page, a
43
+ * sandboxed iframe) is refused for the same reason.
44
+ */
45
+ export const ALLOWED_ORIGINS = Object.freeze([
46
+ 'http://127.0.0.1',
47
+ 'http://localhost',
48
+ 'https://127.0.0.1',
49
+ 'https://localhost',
50
+ ]);
51
+
52
+ /**
53
+ * Is this `Origin` acceptable?
54
+ *
55
+ * A missing `Origin` is allowed: a non-browser client (curl, an SDK) sends none, and the header exists to
56
+ * constrain *browsers*. **Refusing an absent header would break every command-line client while stopping no
57
+ * attack**, because an attacker's browser always sends one.
58
+ *
59
+ * @param {string|undefined} origin
60
+ */
61
+ export function originAllowed(origin) {
62
+ if (origin === undefined || origin === '') return true;
63
+ // Compare origin+port only, so any port on loopback is acceptable.
64
+ let parsed;
65
+ try {
66
+ parsed = new URL(origin);
67
+ } catch {
68
+ return false;
69
+ }
70
+ const bare = `${parsed.protocol}//${parsed.hostname}`;
71
+ return ALLOWED_ORIGINS.includes(bare);
72
+ }
73
+
74
+ /** Read a request body, bounded so a hostile client cannot exhaust memory. */
75
+ const readBody = (req, limit = 4 * 1024 * 1024) => new Promise((resolve, reject) => {
76
+ const chunks = [];
77
+ let size = 0;
78
+ req.on('data', (chunk) => {
79
+ size += chunk.length;
80
+ if (size > limit) {
81
+ reject(new Error(`body exceeds ${limit} bytes`));
82
+ req.destroy();
83
+ return;
84
+ }
85
+ chunks.push(chunk);
86
+ });
87
+ req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
88
+ req.on('error', reject);
89
+ });
90
+
91
+ const sendJson = (res, status, body) => {
92
+ const text = JSON.stringify(body);
93
+ res.writeHead(status, { 'content-type': 'application/json; charset=utf-8', 'content-length': Buffer.byteLength(text) });
94
+ res.end(text);
95
+ };
96
+
97
+ /**
98
+ * Build the request handler.
99
+ *
100
+ * Exported separately from `createMcpHttpServer` so a test can call it with fake request and response objects --
101
+ * **the same reason `handleMessage` is exported**: a socket is not needed to prove framing.
102
+ *
103
+ * @param {{deps: object}} options
104
+ */
105
+ export function createRequestHandler({ deps }) {
106
+ return async (req, res) => {
107
+ const url = new URL(req.url ?? '/', `http://${req.headers.host ?? '127.0.0.1'}`);
108
+
109
+ // 1. Origin, before anything else -- the specification makes this a MUST, and it must precede parsing.
110
+ if (!originAllowed(req.headers.origin)) {
111
+ sendJson(res, 403, { jsonrpc: '2.0', id: null, error: { code: -32600, message: `Origin ${JSON.stringify(req.headers.origin)} is not allowed`, data: { allowed: [...ALLOWED_ORIGINS] } } });
112
+ return;
113
+ }
114
+
115
+ /**
116
+ * 2. A discovery GET, so an operator can see what is here without speaking the protocol.
117
+ *
118
+ * **Gated on an explicit `?discover=1`, and that is not decoration.** A bare `GET /mcp` is what a client
119
+ * written against the removed stream endpoint sends, and it must be told the endpoint is gone rather than
120
+ * handed a document it will fail to parse as a stream. **Two different requests with two different answers,
121
+ * decided by a parameter rather than by guessing at the caller's intent.**
122
+ */
123
+ if (req.method === 'GET' && url.pathname === MCP_PATH && url.searchParams.has('discover')) {
124
+ sendJson(res, 200, {
125
+ server: { name: SERVER_NAME, version: SERVER_VERSION },
126
+ transport: 'streamable-http',
127
+ endpoint: MCP_PATH,
128
+ method: 'POST',
129
+ protocolVersions: [...SUPPORTED_VERSIONS],
130
+ tools: [...TOOL_NAMES],
131
+ });
132
+ return;
133
+ }
134
+
135
+ // 3. The GET *stream* endpoint was removed in 2026-07-28. Refused explicitly rather than 404, because a
136
+ // client written against the older revision should learn why rather than guess.
137
+ if (req.method === 'GET') {
138
+ sendJson(res, 405, { jsonrpc: '2.0', id: null, error: { code: -32600, message: `the GET stream endpoint was removed in protocol revision 2026-07-28; POST to the MCP endpoint instead, or GET ${MCP_PATH}?discover for a description`, data: { endpoint: MCP_PATH } } });
139
+ return;
140
+ }
141
+
142
+ if (req.method !== 'POST') {
143
+ res.writeHead(405, { allow: 'POST, GET' });
144
+ res.end();
145
+ return;
146
+ }
147
+
148
+ if (url.pathname !== MCP_PATH) {
149
+ sendJson(res, 404, { jsonrpc: '2.0', id: null, error: { code: -32601, message: `no endpoint at ${url.pathname}; the MCP endpoint is ${MCP_PATH}` } });
150
+ return;
151
+ }
152
+
153
+ let raw;
154
+ try {
155
+ raw = await readBody(req);
156
+ } catch (error) {
157
+ sendJson(res, 413, { jsonrpc: '2.0', id: null, error: { code: -32600, message: error.message } });
158
+ return;
159
+ }
160
+
161
+ let message;
162
+ try {
163
+ message = JSON.parse(raw);
164
+ } catch (error) {
165
+ // A parse failure is a JSON-RPC error response, not an HTTP 400: the transport delivered the bytes, and
166
+ // the protocol layer is what rejects them.
167
+ sendJson(res, 200, { jsonrpc: '2.0', id: null, error: { code: -32700, message: `invalid JSON: ${error.message}` } });
168
+ return;
169
+ }
170
+
171
+ const reply = await handleMessage(message, deps);
172
+ if (reply === null) {
173
+ // A notification: JSON-RPC forbids a response, and the HTTP binding says so with 202 Accepted.
174
+ res.writeHead(202);
175
+ res.end();
176
+ return;
177
+ }
178
+
179
+ if (url.searchParams.get('stream') === '1') {
180
+ // Request-scoped SSE, for a client written against the stream form. One frame: this server emits no
181
+ // notifications during a call, so there is nothing to interleave.
182
+ res.writeHead(200, { 'content-type': 'text/event-stream; charset=utf-8', 'cache-control': 'no-store', connection: 'keep-alive' });
183
+ res.write(`event: message\ndata: ${JSON.stringify(reply)}\n\n`);
184
+ res.end();
185
+ return;
186
+ }
187
+
188
+ sendJson(res, 200, reply);
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Start the server.
194
+ *
195
+ * **Binds 127.0.0.1 by default.** The specification says a local server SHOULD bind only to loopback, and a
196
+ * fleet-control endpoint reachable from the network is a different threat than one reachable from this machine.
197
+ *
198
+ * @param {{host?: string, port?: number, deps: object}} options
199
+ * @returns {Promise<{server: object, url: string, close: () => Promise<void>}>}
200
+ */
201
+ export function createMcpHttpServer({ host = '127.0.0.1', port = 8766, deps }) {
202
+ const server = createServer(createRequestHandler({ deps }));
203
+ return new Promise((resolve, reject) => {
204
+ server.once('error', reject);
205
+ server.listen(port, host, () => {
206
+ const address = server.address();
207
+ resolve({
208
+ server,
209
+ url: `http://${host}:${address.port}${MCP_PATH}`,
210
+ // A block body, not a concise arrow: `() => done()` returns `done()`'s value into a Promise executor,
211
+ // which nothing reads.
212
+ close: () => new Promise((done) => { server.close(() => { done(); }); }),
213
+ });
214
+ });
215
+ });
216
+ }
@@ -208,7 +208,7 @@ const MAX_STAGES = 16;
208
208
  * `package.json` during `scripts/pack.mjs` rather than hand-maintained, and `W2M_PLUGIN_VERSION`
209
209
  * overrides it at runtime for tests and packagers.
210
210
  */
211
- const PLUGIN_VERSION = '0.54.0';
211
+ const PLUGIN_VERSION = '0.55.0';
212
212
 
213
213
  /** How long any single HTTP call may take before it is aborted, in milliseconds. */
214
214
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;