@twinsearth/w2m-dsh-plugin 0.53.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 +129 -0
- package/bin/w2m-mcp.mjs +378 -0
- package/mcp.json +12 -1
- package/package.json +3 -3
- package/plugin.json +1 -1
- package/skills/memory-sync/SKILL.md +88 -27
- package/src/mcp/http-transport.mjs +216 -0
- package/src/plugin/tools.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,135 @@ 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
|
+
|
|
61
|
+
## [0.54.0] — 2026-10-15
|
|
62
|
+
|
|
63
|
+
**The fleet is published as an MCP server — read-only, and every declared route is checked against the relay's
|
|
64
|
+
own router.**
|
|
65
|
+
|
|
66
|
+
### Five tools, and nothing that can change anything
|
|
67
|
+
|
|
68
|
+
```
|
|
69
|
+
w2m_devices -> /v1/devices
|
|
70
|
+
w2m_stats -> /healthz
|
|
71
|
+
w2m_history -> /v1/tasks
|
|
72
|
+
w2m_report -> /v1/tasks/{id}/report
|
|
73
|
+
w2m_status -> /v1/agents/{id}/status
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**`w2m_run` is deliberately absent.** A server that hands fleet execution to any process able to start it gives
|
|
77
|
+
away the ability to run commands on every machine in the fleet — and this project has already learned that a
|
|
78
|
+
guard which is merely *named* well is not a guard: the `--dry-run` flag in `scripts/ilink-fleet-bridge.mjs`
|
|
79
|
+
**dispatched for real while its name promised otherwise**. So the first version exposes five reads, and a test
|
|
80
|
+
asserts by name that no mutating tool appears — **a later edit adding `w2m_run` has to delete an assertion rather
|
|
81
|
+
than quietly widen what the fleet exposes.**
|
|
82
|
+
|
|
83
|
+
### I invented two routes, and the check that catches that now exists
|
|
84
|
+
|
|
85
|
+
My first draft declared `/v1/stats` and `/v1/status`. **Neither exists.** The relay's complete GET surface is
|
|
86
|
+
`/healthz`, `/metrics`, `/v1/stream`, `/v1/devices`, `/v1/tasks`, `/v1/tasks/{id}`, `/v1/tasks/{id}/report` and
|
|
87
|
+
`/v1/agents/{id}/status`.
|
|
88
|
+
|
|
89
|
+
**A fabricated route does not fail loudly.** The relay answers 404, the MCP client reports a broken server, and
|
|
90
|
+
the bug is in this package — a failure that surfaces in somebody else's tool, at a time nobody connects to this
|
|
91
|
+
commit. So `scripts/mcp-check.mjs` **reads the routes back out of `src/relay/server.mjs` and compares them**, and
|
|
92
|
+
the pre-flight runs it as a fifteenth check. Verified by injection: adding `/v1/invented` turns it red and names
|
|
93
|
+
the route.
|
|
94
|
+
|
|
95
|
+
**Two routes needed canonicalising to compare at all** — the router carries `/v1/tasks/([^/]+)/report` where the
|
|
96
|
+
tool declares `/v1/tasks/{id}/report`. The first version of the check failed on two routes that *are* routed,
|
|
97
|
+
which is its own lesson: **a check that fails on correct input gets disabled.**
|
|
98
|
+
|
|
99
|
+
### Both protocol eras, because the deployed clients are on the older one
|
|
100
|
+
|
|
101
|
+
The current revision (`2026-07-28`) is **stateless** — version, identity and capabilities travel as per-request
|
|
102
|
+
`_meta`, and there is **no handshake**. Every earlier revision opens with `initialize`.
|
|
103
|
+
|
|
104
|
+
**A server implementing only the modern contract is unreachable from most deployed clients**, which is exactly
|
|
105
|
+
the population this exists to serve. So `initialize` is answered for legacy clients, `_meta` is honoured for
|
|
106
|
+
modern ones, and an unsupported version gets the `-32022` error **with the `supported` list**, as the
|
|
107
|
+
specification requires. `server/discover`, which the current revision demands of every server, is implemented.
|
|
108
|
+
|
|
109
|
+
**A notification is never answered** — JSON-RPC forbids it, and `notifications/initialized` is sent by every
|
|
110
|
+
legacy client, so getting that wrong breaks every connection.
|
|
111
|
+
|
|
112
|
+
### stdout carries protocol messages and nothing else
|
|
113
|
+
|
|
114
|
+
Over stdio a stray `console.log` corrupts the stream, and the symptom is a **hung client**, not a chatty server.
|
|
115
|
+
Every diagnostic in this file goes to stderr through one `log()`, and the test drives the real `serve()` loop
|
|
116
|
+
over in-memory streams rather than spawning a process — **because a test that needs a subprocess is skipped
|
|
117
|
+
wherever piped stdio is refused, which is exactly where a protocol regression must still be caught.**
|
|
118
|
+
|
|
119
|
+
### Four mistakes of mine, three of them the same mistake
|
|
120
|
+
|
|
121
|
+
* **`/v1/stats` and `/v1/status` were fabricated.** Read the router; do not write the route you expect.
|
|
122
|
+
* **`mcp-check.mjs` ran its whole check and called `process.exit` on import**, so the consuming suite reported
|
|
123
|
+
**1 test instead of 25** with no failure named. **This is the third file in this project to need the same
|
|
124
|
+
guarded-`main()` fix** — `skill-check.mjs` and `plugin-manifest-check.mjs` were the first two — and each one
|
|
125
|
+
now carries the guard with a comment saying why.
|
|
126
|
+
* **I deleted a doc comment's closing `*/`**, which commented out `export const TOOLS` and produced
|
|
127
|
+
`TOOLS is not defined`. **That is the fourth time in this project that an edit matched anchor text without
|
|
128
|
+
reading the block containing it.**
|
|
129
|
+
* **One assertion failed three times for three different incidental reasons** — a sample value substituted
|
|
130
|
+
where the builder applied a default, a `{id}` that `canonicalRoute` refuses to collapse, and an optional
|
|
131
|
+
property left unset. **The fix was to narrow what it tests**: the property is *"this tool can reach the route
|
|
132
|
+
it declares"*, and the query string is not part of that.
|
|
133
|
+
|
|
134
|
+
**25 new tests, 0 failures.** Full suite 2302 in 85 files. **Pre-flight GREEN with FIFTEEN checks.**
|
|
135
|
+
|
|
7
136
|
## [0.53.0] — 2026-10-15
|
|
8
137
|
|
|
9
138
|
**The shared-memory layer gets a suite, and it corrected three things this session had already "verified".**
|
package/bin/w2m-mcp.mjs
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Publish this fleet as an MCP server, read-only (v0.54.0).
|
|
4
|
+
*
|
|
5
|
+
* ## Why read-only, and why that is the whole design
|
|
6
|
+
*
|
|
7
|
+
* The point of exposing the fleet to Claude Code, Cursor and anything else that speaks MCP is that a *different*
|
|
8
|
+
* agent can ask what the fleet is. **`w2m_run` is deliberately absent.** A server that hands `w2m_run` to any
|
|
9
|
+
* process able to start it gives away fleet execution rights, and this project has already learned that a guard
|
|
10
|
+
* which is merely *named* well is not a guard -- the `--dry-run` flag in `scripts/ilink-fleet-bridge.mjs`
|
|
11
|
+
* dispatched for real while its name promised otherwise.
|
|
12
|
+
*
|
|
13
|
+
* **So the first version exposes five tools that cannot change anything**, and each one is a thin wrapper over
|
|
14
|
+
* an endpoint the plugin already calls. **Nothing new is invented here**; if a tool is not already a read the
|
|
15
|
+
* plugin performs, it is not in this file.
|
|
16
|
+
*
|
|
17
|
+
* ## Two protocol eras, because the clients in the wild are on the older one
|
|
18
|
+
*
|
|
19
|
+
* The current revision (`2026-07-28`) is **modern**: version, identity and capabilities travel as per-request
|
|
20
|
+
* `_meta`, and there is **no handshake**. Every earlier revision (`2025-11-25` and back) is **legacy**: an
|
|
21
|
+
* `initialize` handshake, then `notifications/initialized`, then requests.
|
|
22
|
+
*
|
|
23
|
+
* **A server that implements only the modern contract is unreachable from most deployed clients**, which is
|
|
24
|
+
* exactly the population this exists to serve. So this is **dual-era**: `initialize` is answered for legacy
|
|
25
|
+
* clients, a request carrying `_meta` is answered for modern ones, and an unknown version gets the
|
|
26
|
+
* `-32022 UnsupportedProtocolVersionError` the specification requires, listing what is supported.
|
|
27
|
+
*
|
|
28
|
+
* ## stdout carries protocol messages and nothing else
|
|
29
|
+
*
|
|
30
|
+
* Over stdio, **one stray `console.log` corrupts the stream** and the failure looks like a hung client rather
|
|
31
|
+
* than a chatty server. So every diagnostic in this file goes to **stderr**, and `log()` is the only way to
|
|
32
|
+
* write one. There is no `console.log` here by construction, and the test asserts that.
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
36
|
+
import { join } from 'node:path';
|
|
37
|
+
import { homedir } from 'node:os';
|
|
38
|
+
import { pathToFileURL } from 'node:url';
|
|
39
|
+
|
|
40
|
+
/** Versions this server can speak, newest first. */
|
|
41
|
+
export const SUPPORTED_VERSIONS = Object.freeze(['2026-07-28', '2025-11-25']);
|
|
42
|
+
/** The version assumed when a legacy client sends no `_meta` at all. */
|
|
43
|
+
export const LEGACY_VERSION = '2025-11-25';
|
|
44
|
+
export const SERVER_NAME = 'w2m-dsh-fleet';
|
|
45
|
+
export const SERVER_VERSION = '0.54.0';
|
|
46
|
+
|
|
47
|
+
/** JSON-RPC and MCP error codes this server can return. */
|
|
48
|
+
export const ERROR = Object.freeze({
|
|
49
|
+
PARSE: -32700,
|
|
50
|
+
INVALID_REQUEST: -32600,
|
|
51
|
+
METHOD_NOT_FOUND: -32601,
|
|
52
|
+
INVALID_PARAMS: -32602,
|
|
53
|
+
INTERNAL: -32603,
|
|
54
|
+
UNSUPPORTED_PROTOCOL_VERSION: -32022,
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/** Diagnostics, on stderr only. */
|
|
58
|
+
const log = (message) => process.stderr.write(`[w2m-mcp] ${message}\n`);
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Where the relay and this machine's identity come from.
|
|
62
|
+
*
|
|
63
|
+
* Read from the same sources the plugin uses, so an MCP client sees the fleet the rest of the tooling sees
|
|
64
|
+
* rather than a second, separately configured one.
|
|
65
|
+
*/
|
|
66
|
+
export function readConfig(env = process.env) {
|
|
67
|
+
const home = env.DSH_HOME ?? join(homedir(), '.dsh');
|
|
68
|
+
const deviceFile = env.W2M_DEVICE_FILE ?? join(home, 'xclient', 'device.json');
|
|
69
|
+
let device = null;
|
|
70
|
+
if (existsSync(deviceFile)) {
|
|
71
|
+
try { device = JSON.parse(readFileSync(deviceFile, 'utf8')); } catch (error) { log(`device.json unreadable: ${error.message}`); }
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
relayUrl: env.W2M_RABBIT_URL ?? 'http://202.182.123.154:8787',
|
|
75
|
+
operatorToken: env.W2M_OPERATOR_TOKEN ?? '',
|
|
76
|
+
device,
|
|
77
|
+
deviceFile,
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Headers for a relay call: the device token identifies this machine, the operator token authorises reads. */
|
|
82
|
+
const headers = (config) => {
|
|
83
|
+
const h = { 'content-type': 'application/json' };
|
|
84
|
+
if (config.device?.device_token) h['x-w2m-device-token'] = config.device.device_token;
|
|
85
|
+
if (config.operatorToken !== '') h.authorization = `Bearer ${config.operatorToken}`;
|
|
86
|
+
return h;
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The five read-only tools.
|
|
91
|
+
*
|
|
92
|
+
* ## Every route here was read out of `src/relay/server.mjs`, not guessed
|
|
93
|
+
*
|
|
94
|
+
* The relay's complete GET surface is `/healthz`, `/metrics`, `/v1/stream`, `/v1/devices`, `/v1/tasks`,
|
|
95
|
+
* `/v1/tasks/{id}`, `/v1/tasks/{id}/report` and `/v1/agents/{id}/status`. **My first draft of this file
|
|
96
|
+
* invented `/v1/stats` and `/v1/status`, which do not exist** -- and a tool whose route is fabricated returns
|
|
97
|
+
* a 404 that reads as a broken relay rather than as a bug in this file.
|
|
98
|
+
*
|
|
99
|
+
* ## These are relay reads, not the DSH tools
|
|
100
|
+
*
|
|
101
|
+
* `w2m_stats` and `w2m_status` in the plugin both begin from `/healthz`, and each adds context the relay
|
|
102
|
+
* cannot know: which profile directory is in play, where `rabbitUrl` came from, whether a bundled runtime
|
|
103
|
+
* resolved. **That context lives in the plugin's process, so an MCP client calling this server gets the relay's
|
|
104
|
+
* half of the answer.** The descriptions say so rather than implying parity.
|
|
105
|
+
*
|
|
106
|
+
* `routes` names the paths a tool may call, and `scripts/mcp-check.mjs` asserts each one appears in the
|
|
107
|
+
* relay's router -- **so a fabricated route fails the pre-flight instead of failing in someone's client.**
|
|
108
|
+
*/
|
|
109
|
+
export const TOOLS = Object.freeze([
|
|
110
|
+
{
|
|
111
|
+
name: 'w2m_devices',
|
|
112
|
+
description: 'List the machines the relay holds a lease for: id, name, platform, online state and last-seen. Read-only. Answers "who is in the fleet" without running anything.',
|
|
113
|
+
routes: ['/v1/devices'],
|
|
114
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
115
|
+
requests: () => ['/v1/devices'],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
name: 'w2m_stats',
|
|
119
|
+
description: "The relay's own snapshot of the fleet: relay identity, uptime, device and task counts, aggregate round-trip times, and whether the relay restarted. Read-only. This is the relay's half of the answer -- the parts of the plugin's w2m_stats that come from the local profile are not reachable over MCP.",
|
|
120
|
+
routes: ['/healthz'],
|
|
121
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
122
|
+
requests: () => ['/healthz'],
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: 'w2m_history',
|
|
126
|
+
description: 'List recent tasks newest first: id, mode, shard count, which machines hold leases, and whether each was cancelled. Read-only, and deliberately narrower than w2m_status: it names what ran without fetching any result.',
|
|
127
|
+
inputSchema: {
|
|
128
|
+
type: 'object',
|
|
129
|
+
properties: {
|
|
130
|
+
limit: { type: 'integer', minimum: 1, maximum: 200, description: 'How many tasks to return, newest first. Defaults to 20.' },
|
|
131
|
+
},
|
|
132
|
+
additionalProperties: false,
|
|
133
|
+
},
|
|
134
|
+
routes: ['/v1/tasks'],
|
|
135
|
+
requests: (args) => {
|
|
136
|
+
const raw = args.limit;
|
|
137
|
+
if (raw === undefined) return ['/v1/tasks'];
|
|
138
|
+
if (!Number.isInteger(raw) || raw < 1) throw new Error(`limit must be a positive integer, got ${JSON.stringify(raw)}`);
|
|
139
|
+
return [`/v1/tasks?limit=${raw}`];
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
{
|
|
143
|
+
name: 'w2m_report',
|
|
144
|
+
description: 'Fetch the rendered report for one finished task as markdown or json. Markdown is the human summary; json is the same aggregate shape w2m_status returns. Read-only, and does not start, resume or cancel anything.',
|
|
145
|
+
inputSchema: {
|
|
146
|
+
type: 'object',
|
|
147
|
+
properties: {
|
|
148
|
+
task_id: { type: 'string', minLength: 1, description: 'Task id returned by a dispatch.' },
|
|
149
|
+
format: { type: 'string', enum: ['md', 'json'], description: 'md for the rendered report, json for the aggregate. Defaults to md.' },
|
|
150
|
+
},
|
|
151
|
+
required: ['task_id'],
|
|
152
|
+
additionalProperties: false,
|
|
153
|
+
},
|
|
154
|
+
routes: ['/v1/tasks/{id}/report'],
|
|
155
|
+
requests: (args) => {
|
|
156
|
+
// A task id is opaque, but it lands in a URL path -- so anything that could change which endpoint is
|
|
157
|
+
// addressed is refused rather than escaped into place.
|
|
158
|
+
const id = String(args.task_id ?? '');
|
|
159
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) throw new Error(`task_id ${JSON.stringify(id)} is not a plausible task id`);
|
|
160
|
+
return [`/v1/tasks/${id}/report?format=${args.format === 'json' ? 'json' : 'md'}`];
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
name: 'w2m_status',
|
|
165
|
+
description: "This plugin's own view of itself: the machine identity on disk, which relay it is talking to and where that address came from, whether the relay answers, and this machine's round-trip time. Read-only, and usable while the configuration is still broken.",
|
|
166
|
+
routes: ['/v1/agents/{id}/status'],
|
|
167
|
+
inputSchema: {
|
|
168
|
+
type: 'object',
|
|
169
|
+
properties: {
|
|
170
|
+
machine_id: { type: 'string', minLength: 1, description: 'Machine id, as listed by w2m_devices.' },
|
|
171
|
+
},
|
|
172
|
+
required: ['machine_id'],
|
|
173
|
+
additionalProperties: false,
|
|
174
|
+
},
|
|
175
|
+
requests: (args) => {
|
|
176
|
+
const id = String(args.machine_id ?? '');
|
|
177
|
+
if (!/^[A-Za-z0-9_-]{1,64}$/.test(id)) throw new Error(`machine_id ${JSON.stringify(id)} is not a plausible machine id`);
|
|
178
|
+
return [`/v1/agents/${id}/status`];
|
|
179
|
+
},
|
|
180
|
+
},
|
|
181
|
+
]);
|
|
182
|
+
|
|
183
|
+
/** Tool names, for the tests and for a caller that wants the list without the schemas. */
|
|
184
|
+
export const TOOL_NAMES = Object.freeze(TOOLS.map((t) => t.name));
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Perform one tool call.
|
|
188
|
+
*
|
|
189
|
+
* Returns the MCP `tools/call` result shape. **A failure is reported as `isError: true` with the reason in the
|
|
190
|
+
* text rather than as a JSON-RPC error**, because the model can act on the former and a transport-level error
|
|
191
|
+
* reads as a broken server.
|
|
192
|
+
*
|
|
193
|
+
* @param {string} name
|
|
194
|
+
* @param {object} args
|
|
195
|
+
* @param {{config: object, fetchImpl: Function}} deps
|
|
196
|
+
*/
|
|
197
|
+
export async function callTool(name, args, deps) {
|
|
198
|
+
const tool = TOOLS.find((t) => t.name === name);
|
|
199
|
+
if (tool === undefined) {
|
|
200
|
+
return { content: [{ type: 'text', text: `no such tool: ${name}. This server exposes ${TOOL_NAMES.join(', ')}.` }], isError: true };
|
|
201
|
+
}
|
|
202
|
+
let paths;
|
|
203
|
+
try {
|
|
204
|
+
paths = tool.requests(args ?? {});
|
|
205
|
+
} catch (error) {
|
|
206
|
+
return { content: [{ type: 'text', text: `bad arguments: ${error.message}` }], isError: true };
|
|
207
|
+
}
|
|
208
|
+
const base = deps.config.relayUrl.replace(/\/+$/, '');
|
|
209
|
+
try {
|
|
210
|
+
// Sequential rather than concurrent: a tool that reads two endpoints should report the first failure rather
|
|
211
|
+
// than racing two errors against each other.
|
|
212
|
+
const parts = [];
|
|
213
|
+
for (const path of paths) {
|
|
214
|
+
const response = await deps.fetchImpl(`${base}${path}`, { headers: headers(deps.config) });
|
|
215
|
+
const body = await response.text();
|
|
216
|
+
if (!response.ok) {
|
|
217
|
+
return { content: [{ type: 'text', text: `the relay answered ${response.status} for ${name} ${path}: ${body.slice(0, 400)}` }], isError: true };
|
|
218
|
+
}
|
|
219
|
+
parts.push(paths.length === 1 ? body : `${path}\n${body}`);
|
|
220
|
+
}
|
|
221
|
+
return { content: [{ type: 'text', text: parts.join('\n\n') }], isError: false };
|
|
222
|
+
} catch (error) {
|
|
223
|
+
return { content: [{ type: 'text', text: `could not reach the relay at ${deps.config.relayUrl}: ${error.message}` }], isError: true };
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Build a JSON-RPC success or error envelope. */
|
|
228
|
+
const ok = (id, result) => ({ jsonrpc: '2.0', id, result });
|
|
229
|
+
const fail = (id, code, message, data) => ({ jsonrpc: '2.0', id, error: { code, message, ...(data === undefined ? {} : { data }) } });
|
|
230
|
+
|
|
231
|
+
/** The protocol version a request declares, from `_meta`, or null for a legacy client. */
|
|
232
|
+
const declaredVersion = (request) => {
|
|
233
|
+
const meta = request?.params?._meta;
|
|
234
|
+
const v = meta?.['io.modelcontextprotocol/protocolVersion'];
|
|
235
|
+
return typeof v === 'string' ? v : null;
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Answer one JSON-RPC message, or return null when nothing should be written.
|
|
240
|
+
*
|
|
241
|
+
* A notification has no `id` and **MUST NOT** be answered -- writing a reply to one is a protocol violation
|
|
242
|
+
* that some clients treat as a desynchronised stream.
|
|
243
|
+
*
|
|
244
|
+
* @param {object} request
|
|
245
|
+
* @param {{config: object, fetchImpl: Function}} deps
|
|
246
|
+
* @returns {Promise<object|null>}
|
|
247
|
+
*/
|
|
248
|
+
export async function handleMessage(request, deps) {
|
|
249
|
+
if (request === null || typeof request !== 'object' || Array.isArray(request)) {
|
|
250
|
+
return fail(null, ERROR.INVALID_REQUEST, 'not a JSON-RPC request object');
|
|
251
|
+
}
|
|
252
|
+
const { id, method, params } = request;
|
|
253
|
+
const isNotification = id === undefined || id === null;
|
|
254
|
+
|
|
255
|
+
const version = declaredVersion(request);
|
|
256
|
+
if (version !== null && !SUPPORTED_VERSIONS.includes(version)) {
|
|
257
|
+
// The specification requires the supported list, so a client can retry rather than give up.
|
|
258
|
+
return isNotification ? null : fail(id, ERROR.UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', { supported: [...SUPPORTED_VERSIONS], requested: version });
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
switch (method) {
|
|
262
|
+
case 'initialize': {
|
|
263
|
+
// Legacy handshake. `protocolVersion` echoes the client's choice when it is one this server speaks.
|
|
264
|
+
const asked = params?.protocolVersion;
|
|
265
|
+
const chosen = SUPPORTED_VERSIONS.includes(asked) ? asked : LEGACY_VERSION;
|
|
266
|
+
return ok(id, {
|
|
267
|
+
protocolVersion: chosen,
|
|
268
|
+
capabilities: { tools: { listChanged: false } },
|
|
269
|
+
serverInfo: { name: SERVER_NAME, version: SERVER_VERSION },
|
|
270
|
+
instructions: 'Read-only view of a W2M fleet. These tools report; none of them can dispatch a command or change any machine.',
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
case 'notifications/initialized':
|
|
275
|
+
// A notification: acknowledged by doing nothing, never answered.
|
|
276
|
+
return null;
|
|
277
|
+
|
|
278
|
+
case 'server/discover':
|
|
279
|
+
// Required of every server by the current revision: name the versions supported.
|
|
280
|
+
return ok(id, { protocolVersion: SUPPORTED_VERSIONS[0], supportedVersions: [...SUPPORTED_VERSIONS], serverInfo: { name: SERVER_NAME, version: SERVER_VERSION } });
|
|
281
|
+
|
|
282
|
+
case 'tools/list':
|
|
283
|
+
return ok(id, { tools: TOOLS.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) });
|
|
284
|
+
|
|
285
|
+
case 'tools/call': {
|
|
286
|
+
const name = params?.name;
|
|
287
|
+
if (typeof name !== 'string' || name === '') return fail(id, ERROR.INVALID_PARAMS, 'tools/call requires a tool name');
|
|
288
|
+
return ok(id, await callTool(name, params?.arguments ?? {}, deps));
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
case 'ping':
|
|
292
|
+
return ok(id, {});
|
|
293
|
+
|
|
294
|
+
default:
|
|
295
|
+
if (isNotification) return null;
|
|
296
|
+
return fail(id, ERROR.METHOD_NOT_FOUND, `method not found: ${String(method)}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Read newline-delimited JSON from `input` and answer each message on `output`.
|
|
302
|
+
*
|
|
303
|
+
* Exported so the test drives the same loop the process runs, without spawning anything -- a suite that needs a
|
|
304
|
+
* subprocess is skipped wherever piped stdio is refused, which is exactly where this needs to be testable.
|
|
305
|
+
*/
|
|
306
|
+
export function serve({ input, output, deps }) {
|
|
307
|
+
let buffer = '';
|
|
308
|
+
let pending = Promise.resolve();
|
|
309
|
+
input.setEncoding('utf8');
|
|
310
|
+
input.on('data', (chunk) => {
|
|
311
|
+
buffer += chunk;
|
|
312
|
+
let newline = buffer.indexOf('\n');
|
|
313
|
+
while (newline >= 0) {
|
|
314
|
+
const line = buffer.slice(0, newline).trim();
|
|
315
|
+
buffer = buffer.slice(newline + 1);
|
|
316
|
+
if (line !== '') {
|
|
317
|
+
let parsed;
|
|
318
|
+
try {
|
|
319
|
+
parsed = JSON.parse(line);
|
|
320
|
+
} catch (error) {
|
|
321
|
+
output.write(`${JSON.stringify(fail(null, ERROR.PARSE, `invalid JSON: ${error.message}`))}\n`);
|
|
322
|
+
newline = buffer.indexOf('\n');
|
|
323
|
+
continue;
|
|
324
|
+
}
|
|
325
|
+
// Serialised so two calls cannot interleave their frames on stdout.
|
|
326
|
+
pending = pending.then(async () => {
|
|
327
|
+
const reply = await handleMessage(parsed, deps);
|
|
328
|
+
if (reply !== null) output.write(`${JSON.stringify(reply)}\n`);
|
|
329
|
+
}).catch((error) => {
|
|
330
|
+
log(`handler threw: ${error?.stack ?? error}`);
|
|
331
|
+
output.write(`${JSON.stringify(fail(parsed?.id ?? null, ERROR.INTERNAL, String(error?.message ?? error)))}\n`);
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
newline = buffer.indexOf('\n');
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
return pending;
|
|
338
|
+
}
|
|
339
|
+
|
|
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);
|
|
352
|
+
const config = readConfig();
|
|
353
|
+
log(`starting; relay ${config.relayUrl}; device ${config.device?.machine_name ?? '(none)'}`);
|
|
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
|
+
|
|
374
|
+
serve({ input: process.stdin, output: process.stdout, deps: { config, fetchImpl: fetch } });
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Only when run as a program, so importing this module in a test starts no server.
|
|
378
|
+
if (process.argv[1] !== undefined && import.meta.url === pathToFileURL(process.argv[1]).href) main();
|
package/mcp.json
CHANGED
|
@@ -1,4 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
|
|
3
|
-
"mcpServers": {
|
|
3
|
+
"mcpServers": {
|
|
4
|
+
"w2m-fleet": {
|
|
5
|
+
"type": "stdio",
|
|
6
|
+
"command": "node",
|
|
7
|
+
"args": ["./bin/w2m-mcp.mjs"],
|
|
8
|
+
"cwd": "${PLUGIN_ROOT}"
|
|
9
|
+
},
|
|
10
|
+
"w2m-fleet-http": {
|
|
11
|
+
"type": "streamable-http",
|
|
12
|
+
"url": "http://127.0.0.1:8766/mcp"
|
|
13
|
+
}
|
|
14
|
+
}
|
|
4
15
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/w2m-dsh-plugin",
|
|
3
|
-
"version": "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/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/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.
|
|
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": [
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: memory-sync
|
|
3
|
-
description: Use when writing or reading a block through w2m_memory, when a field was omitted rather than nulled, when a block names an ancestor the caller cannot produce, or when deciding whether a fact belongs in durable memory at all. Covers
|
|
3
|
+
description: Use when writing or reading a block through w2m_memory, when a field was omitted rather than nulled, when a block names an ancestor the caller cannot produce, or when deciding whether a fact belongs in durable memory at all. Covers the seven CAT7 fields by their real names, the per-field cost a receiver pays, the one field no authority can accept, and why a refused ancestor is never re-rooted.
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Memory sync
|
|
@@ -8,54 +8,115 @@ description: Use when writing or reading a block through w2m_memory, when a fiel
|
|
|
8
8
|
`w2m_memory` stores blocks that carry **provenance**, and it decides field by field what each reader may see.
|
|
9
9
|
The tool is small; the rules are the part that matters.
|
|
10
10
|
|
|
11
|
+
## The seven fields, by their real names
|
|
12
|
+
|
|
13
|
+
```
|
|
14
|
+
CAT7_FIELDS = [origin, subject, body, evidence, confidence, observed_at, lineage]
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
**The order is part of the hash**, so a block is not a bag of fields — it is a canonical serialization.
|
|
18
|
+
`blockHash` covers all seven, which is why tampering with any one of them fails `verifyBlock` with
|
|
19
|
+
`BLOCK_HASH_MISMATCH`.
|
|
20
|
+
|
|
21
|
+
They split into exactly two groups, and the split is the reason a receiver cannot rewrite provenance:
|
|
22
|
+
|
|
23
|
+
| Group | Fields | Why |
|
|
24
|
+
|---|---|---|
|
|
25
|
+
| **inherited** | `origin`, `observed_at`, `lineage` | where it came from and when it was seen — **never offered to a receiver's chooser** |
|
|
26
|
+
| **receiver-choosable** | `subject`, `body`, `evidence`, `confidence` | what the receiver decides to keep |
|
|
27
|
+
|
|
28
|
+
**The two lists partition the seven**, so neither can grow without the other shrinking.
|
|
29
|
+
|
|
30
|
+
### Names that are NOT part of this implementation
|
|
31
|
+
|
|
32
|
+
A description circulating elsewhere names the seven as *focus, issue, intent, motivation, commitment,
|
|
33
|
+
perspective, mood*, and refers to **`SVAF`** and **`remix`**. **None of those names exist here.** Measured:
|
|
34
|
+
`SVAF` and `remix` occur **zero** times in the source. The mechanisms have different names:
|
|
35
|
+
|
|
36
|
+
| Described elsewhere | What this implementation actually has |
|
|
37
|
+
|---|---|
|
|
38
|
+
| the seven-field cognitive model | `CAT7_FIELDS`, as listed above |
|
|
39
|
+
| `SVAF`, per-receiver role anchoring | `RECEIVER_CHOOSABLE_FIELDS` + `acceptField(field, actor)` |
|
|
40
|
+
| lineage by content-hash parent | `lineage` + `blockHash` + `ancestry` + `isDescendantOf` |
|
|
41
|
+
| `remix` storing only the receiver's own evaluation | `INHERITED_FIELDS` and `defaultChoose` |
|
|
42
|
+
|
|
43
|
+
**Writing the other names produces `BLOCK_UNKNOWN_FIELD` or `FIELD_NOT_A_FIELD`**, so a block built from that
|
|
44
|
+
description is refused at the wire rather than silently misread.
|
|
45
|
+
|
|
46
|
+
## Cost is per field, and one field is not a threshold
|
|
47
|
+
|
|
48
|
+
```js
|
|
49
|
+
FIELD_LEVELS = { subject: 0, body: 0, evidence: 50 }
|
|
50
|
+
NEVER_ACCEPTED = { confidence: "a receiver does not adopt the sender's certainty (invariant, not a default)" }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**`evidence` costs 50** — the bottom of the `captain` band, which is why the number is what it is. Level 49 is
|
|
54
|
+
refused with `FIELD_BELOW_AUTHORITY` and `required: 50`; level 50 is allowed.
|
|
55
|
+
|
|
56
|
+
**`confidence` is refused at every level, including 90 and above**, with `FIELD_ALWAYS_REFUSED` and
|
|
57
|
+
`required: null`. **The reason string says "invariant" on purpose: a reader who thinks it is a threshold will
|
|
58
|
+
try to raise it.** There is no level that satisfies it.
|
|
59
|
+
|
|
60
|
+
`origin`, `observed_at` and `lineage` answer `FIELD_UNDECLARED` to `acceptField` — **which does not mean
|
|
61
|
+
"forbidden".** They have no rule because **a receiver does not choose them**, and `acceptWithAuthority` still
|
|
62
|
+
returns them in `accepted`. **Reading `UNDECLARED` as a refusal would drop the ancestry that makes the block
|
|
63
|
+
meaningful.**
|
|
64
|
+
|
|
11
65
|
## Acceptance is per field, and omission is not null
|
|
12
66
|
|
|
13
|
-
|
|
14
|
-
|
|
67
|
+
When an actor's authority cannot cover a field, **the field is absent from what they receive — it is not
|
|
68
|
+
present-with-`null`, and it is not an error.**
|
|
15
69
|
|
|
16
70
|
```
|
|
17
|
-
accepted = [subject, body]
|
|
18
|
-
|
|
71
|
+
level 0 accepted = [origin, observed_at, lineage, subject, body] refused = [evidence, confidence]
|
|
72
|
+
level 50 accepted = [origin, observed_at, lineage, subject, body, evidence] refused = [confidence]
|
|
19
73
|
```
|
|
20
74
|
|
|
21
75
|
**Test for the key, not for a falsy value.** `if (!block.evidence)` cannot tell "omitted" from "empty", and
|
|
22
76
|
those mean different things: one is a permission decision, the other is content.
|
|
23
77
|
|
|
78
|
+
`acceptBlock` returns a **partition**, not a filtered copy — *"the refused half is evidence: 'this machine
|
|
79
|
+
declined the confidence and kept the body' is a decision worth being able to inspect, and a filtered object
|
|
80
|
+
silently loses it."*
|
|
81
|
+
|
|
24
82
|
**Never reconstruct a refused field.** If you need it, the answer is a higher-authority actor or a different
|
|
25
83
|
block, not a guess.
|
|
26
84
|
|
|
27
85
|
## A block cannot name an ancestor its caller cannot produce
|
|
28
86
|
|
|
29
|
-
|
|
30
|
-
|
|
87
|
+
`acceptBlock(block, { lineage })` refuses a block whose `lineage` names an ancestor outside the set the
|
|
88
|
+
receiver can produce:
|
|
31
89
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
90
|
+
```
|
|
91
|
+
{ accepted: null, refused: [{ field: 'lineage', why: 'BLOCK_MISSING_PARENT: cannot produce ancestor(s) …;
|
|
92
|
+
refusing rather than re-rooting' }], accepted_fields: [], verified: true }
|
|
93
|
+
```
|
|
35
94
|
|
|
36
|
-
|
|
95
|
+
**`verified: true` — the block itself is well formed; the ancestry is what is absent.** And it is refused
|
|
96
|
+
rather than re-rooted, for the reason the source states:
|
|
37
97
|
|
|
38
|
-
|
|
98
|
+
> Re-rooting — keeping the block and emptying its lineage — turns a claim with an unverifiable history into one
|
|
99
|
+
> with **no history**, which reads as a fresh, independent assertion. **That is precisely how a fabricated
|
|
100
|
+
> claim acquires respectability.**
|
|
39
101
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
| `captain` | 50–69 | more, including some evidence |
|
|
44
|
-
| `org_admin` | 70–89 | `evidence` and above |
|
|
45
|
-
| `system_admin` | 90+ | everything |
|
|
102
|
+
**`ancestry` answers a different question and does not fail.** It takes a `Map<hash, block>` and returns
|
|
103
|
+
`{chain, missing, cycle, depth}` — **there is no `ok` and no `code`.** Gaps are **collected** in `missing`
|
|
104
|
+
because *"knowing how much of a history is absent is more useful than knowing that some of it is."*
|
|
46
105
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
106
|
+
**`isDescendantOf` returns `{descendant, chain, missing, checked}` — not a boolean.** It returns more than a
|
|
107
|
+
boolean on purpose: *"a claim that descends from something this machine already thought is an **echo**, not
|
|
108
|
+
corroboration, and a fleet that cannot tell those apart will confidently agree with itself."*
|
|
50
109
|
|
|
51
|
-
|
|
52
|
-
|
|
110
|
+
**You cannot construct a cycle.** `blockHash` covers `lineage`, so rebuilding a block changes its hash and the
|
|
111
|
+
edge no longer closes — a real cycle would need a hash preimage. `BLOCK_CYCLE` guards a `have` map whose keys
|
|
112
|
+
disagree with the blocks under them: **a corrupted or hostile store, where terminating is the point.**
|
|
53
113
|
|
|
54
114
|
## Writing: say what you can prove
|
|
55
115
|
|
|
56
116
|
- **Name the ancestor only if you hold it.** A parent you cannot produce fails the whole block.
|
|
57
|
-
- **Put the load-bearing fact in the cheapest field that can carry it.** A fact in `evidence` is invisible
|
|
58
|
-
|
|
117
|
+
- **Put the load-bearing fact in the cheapest field that can carry it.** A fact in `evidence` is invisible
|
|
118
|
+
below level 50, so a block whose meaning lives in `evidence` means nothing to most readers.
|
|
119
|
+
- **Never write `confidence` expecting it to travel.** It is refused by every receiver by construction.
|
|
59
120
|
- **One block, one claim.** Ancestry is how blocks relate; packing two claims into one block makes each
|
|
60
121
|
unreadable to whoever may see only one of them.
|
|
61
122
|
- **Expect omission to be silent.** Nothing in the result says "a field was hidden from you" beyond the field
|
|
@@ -65,5 +126,5 @@ work. **Model the read you actually need.**
|
|
|
65
126
|
|
|
66
127
|
1. What is the **cheapest field** that can carry this fact?
|
|
67
128
|
2. Do you **hold the parent** you are about to name?
|
|
68
|
-
3. Who needs to read this, and **what band do they hold**?
|
|
69
|
-
belong in
|
|
129
|
+
3. Who needs to read this, and **what band do they hold**? `ordinary` is 0–49, `captain` 50–69, `org_admin`
|
|
130
|
+
70–89, `system_admin` 90+. If the answer is "everyone", the fact does not belong in `evidence`.
|
|
@@ -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
|
+
}
|
package/src/plugin/tools.mjs
CHANGED
|
@@ -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.
|
|
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;
|