@skastr0/prism-sdk 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +12 -0
- package/dist/compile-manifest.d.ts +293 -0
- package/dist/compile-manifest.js +232 -0
- package/dist/mcp/daemon-resolver.d.ts +100 -0
- package/dist/mcp/daemon-resolver.js +185 -0
- package/dist/mcp/shim.d.ts +155 -0
- package/dist/mcp/shim.js +434 -0
- package/dist/mcp/uds-path.d.ts +26 -0
- package/dist/mcp/uds-path.js +70 -0
- package/dist/mcp/uds-registry.d.ts +109 -0
- package/dist/mcp/uds-registry.js +270 -0
- package/dist/mcp/uds-singleton.d.ts +124 -0
- package/dist/mcp/uds-singleton.js +360 -0
- package/dist/refs.d.ts +15 -0
- package/dist/refs.js +26 -0
- package/dist/snapshot.d.ts +92 -0
- package/dist/snapshot.js +74 -0
- package/dist/stable-json.d.ts +8 -0
- package/dist/stable-json.js +27 -0
- package/package.json +73 -0
package/dist/mcp/shim.js
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Aggregating MCP shim.
|
|
3
|
+
*
|
|
4
|
+
* A single stdio MCP server, spawned once by the harness, that fronts N
|
|
5
|
+
* Prism-generated plugin daemons over Unix domain sockets. It replaces the
|
|
6
|
+
* "one spawned process per plugin" shape with "one spawned process,
|
|
7
|
+
* multiplexed over UDS to N already-running daemons":
|
|
8
|
+
*
|
|
9
|
+
* harness <--stdio(JSON-RPC)--> shim <--HTTP-over-UDS--> daemon[plugin A]
|
|
10
|
+
* \-HTTP-over-UDS--> daemon[plugin B]
|
|
11
|
+
*
|
|
12
|
+
* Harness side (this module's `runShim`/`createShimServer`): a bare
|
|
13
|
+
* JSON-RPC MCP server over stdin/stdout using the SDK's low-level `Server`
|
|
14
|
+
* + `StdioServerTransport`. Stdio is a single duplex pipe with exactly one
|
|
15
|
+
* peer, so there is no SSE and no HTTP session concept to manage here —
|
|
16
|
+
* that machinery only exists on the daemon side.
|
|
17
|
+
*
|
|
18
|
+
* Daemon side (`DaemonConnection`): a minimal hand-rolled HTTP-over-UDS
|
|
19
|
+
* JSON-RPC client, deliberately not the SDK's `Client` +
|
|
20
|
+
* `StreamableHTTPClientTransport`. The SDK client runs every response
|
|
21
|
+
* through `safeParse(resultSchema, ...)`, which is a plain (non-passthrough)
|
|
22
|
+
* Zod object and silently drops any field the schema does not declare. This
|
|
23
|
+
* shim's job is to forward a daemon's tool-call result value-preserving —
|
|
24
|
+
* stripping no field (name mapping aside) — so the daemon's raw JSON-RPC
|
|
25
|
+
* `result`/`error` is read and handed back untouched instead of being
|
|
26
|
+
* round-tripped through schema validation on the way in.
|
|
27
|
+
*
|
|
28
|
+
* A plugin absent from the UDS registry, whose recorded daemon is dead, or
|
|
29
|
+
* whose recorded bundle hash no longer matches the compiled bundle on disk,
|
|
30
|
+
* is resolved via `daemon-resolver.ts`'s `resolveOrSpawnDaemon`: it spawns a
|
|
31
|
+
* fresh daemon and waits (bounded by a spawn timeout) for it to register
|
|
32
|
+
* and answer live. If that also fails, or a live daemon is unreachable
|
|
33
|
+
* within `daemonTimeoutMs`, the failure is never fatal to the shim: the
|
|
34
|
+
* plugin's tools are omitted from the merged `tools/list`, and a
|
|
35
|
+
* `tools/call` naming it returns a typed `McpError` (SDK
|
|
36
|
+
* `ErrorCode.ConnectionClosed`). Every other configured plugin keeps
|
|
37
|
+
* serving normally — each plugin's request runs against its own timeout and
|
|
38
|
+
* failure is caught per-plugin.
|
|
39
|
+
*/
|
|
40
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
41
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
42
|
+
import { CallToolRequestSchema, ListToolsRequestSchema, ErrorCode, LATEST_PROTOCOL_VERSION, McpError, } from "@modelcontextprotocol/sdk/types.js";
|
|
43
|
+
import { getDaemon as getDaemonDefault } from "./uds-registry.js";
|
|
44
|
+
import { resolveOrSpawnDaemon, DaemonResolveError, DEFAULT_SPAWN_TIMEOUT_MS } from "./daemon-resolver.js";
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Naming: the per-plugin namespace segment used to flatten N plugins' tools
|
|
47
|
+
// into the one flat name-space a single MCP `tools/list` response can hold.
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
/**
|
|
50
|
+
* FNV-1a, 8 hex chars. Deliberately bit-for-bit identical to
|
|
51
|
+
* `stableHash8` in the root package's `src/compile/stable-hash.ts`, which
|
|
52
|
+
* backs `generatedMcpWireServerName` (`p_<hash8>`) — the segment every
|
|
53
|
+
* harness lowerer (Claude Code, Codex CLI, Antigravity, Factory Droid,
|
|
54
|
+
* Kimi Code, Cursor, Grok) already bakes into compiled permission strings
|
|
55
|
+
* and tool FQNs (e.g. Claude Code's `mcp__p_<hash8>__<tool>`).
|
|
56
|
+
*
|
|
57
|
+
* `packages/prism-sdk` cannot import that root-package module (its
|
|
58
|
+
* `tsconfig.json` `rootDir` is `./src`; a real `import` of anything under
|
|
59
|
+
* the workspace root `src/` fails to compile), so the tiny pure function is
|
|
60
|
+
* duplicated here rather than re-derived with a different algorithm. Using
|
|
61
|
+
* the *same* namespace segment the harness side already computes means a
|
|
62
|
+
* later wave that points a harness's MCP config at this shim only has to
|
|
63
|
+
* add an outer wrapper layer around `p_<hash8>__<tool>` (the shim's own
|
|
64
|
+
* server key in that harness's config), not rename every already-compiled
|
|
65
|
+
* permission string from scratch.
|
|
66
|
+
*/
|
|
67
|
+
const stableHash8 = (input) => {
|
|
68
|
+
let hash = 0x811c9dc5;
|
|
69
|
+
for (let index = 0; index < input.length; index++) {
|
|
70
|
+
hash ^= input.codePointAt(index);
|
|
71
|
+
hash = Math.trunc(Math.imul(hash, 0x01000193));
|
|
72
|
+
}
|
|
73
|
+
return (hash >>> 0).toString(16).padStart(8, "0");
|
|
74
|
+
};
|
|
75
|
+
/** `p_<hash8>` — see `stableHash8` above for why this exact shape. */
|
|
76
|
+
export const pluginWireNamespace = (pluginName) => `p_${stableHash8(pluginName)}`;
|
|
77
|
+
const NAMESPACE_SEPARATOR = "__";
|
|
78
|
+
/** Fixed width of `p_<hash8>`: literal `p_` (2) + 8 hex chars. */
|
|
79
|
+
const NAMESPACE_LENGTH = 2 + 8;
|
|
80
|
+
/** Harness-visible tool name: `<pluginWireNamespace>__<originalToolName>`. */
|
|
81
|
+
export const namespacedToolName = (pluginName, toolName) => `${pluginWireNamespace(pluginName)}${NAMESPACE_SEPARATOR}${toolName}`;
|
|
82
|
+
/**
|
|
83
|
+
* Inverse of `namespacedToolName`. The namespace segment has a fixed
|
|
84
|
+
* width, so this splits on position rather than searching for the `__`
|
|
85
|
+
* separator — a tool name is free-form and may itself legally contain
|
|
86
|
+
* `__`, but the namespace segment never does.
|
|
87
|
+
*/
|
|
88
|
+
export const splitNamespacedToolName = (fqName) => {
|
|
89
|
+
if (fqName.length <= NAMESPACE_LENGTH + NAMESPACE_SEPARATOR.length)
|
|
90
|
+
return undefined;
|
|
91
|
+
const namespace = fqName.slice(0, NAMESPACE_LENGTH);
|
|
92
|
+
const separator = fqName.slice(NAMESPACE_LENGTH, NAMESPACE_LENGTH + NAMESPACE_SEPARATOR.length);
|
|
93
|
+
if (separator !== NAMESPACE_SEPARATOR)
|
|
94
|
+
return undefined;
|
|
95
|
+
const toolName = fqName.slice(NAMESPACE_LENGTH + NAMESPACE_SEPARATOR.length);
|
|
96
|
+
if (toolName.length === 0)
|
|
97
|
+
return undefined;
|
|
98
|
+
return { namespace, toolName };
|
|
99
|
+
};
|
|
100
|
+
// ---------------------------------------------------------------------------
|
|
101
|
+
// Daemon-facing HTTP-over-UDS client
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
export class ShimDaemonError extends Error {
|
|
104
|
+
pluginName;
|
|
105
|
+
cause;
|
|
106
|
+
kind = "shim-daemon-error";
|
|
107
|
+
constructor(pluginName, message, cause) {
|
|
108
|
+
super(`[${pluginName}] ${message}`);
|
|
109
|
+
this.pluginName = pluginName;
|
|
110
|
+
this.cause = cause;
|
|
111
|
+
this.name = "ShimDaemonError";
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* One JSON-RPC-over-UDS connection to a single plugin daemon, matching the
|
|
116
|
+
* contract `src/compile/mcp-bundle.ts`'s `MCP_SDK_HTTP_RUNTIME` template
|
|
117
|
+
* implements: POST to `/mcp` with `mcp-protocol-version`, `initialize`
|
|
118
|
+
* first to obtain `mcp-session-id`, then that header on every subsequent
|
|
119
|
+
* call. Initializes once and reuses the session; a failed request drops
|
|
120
|
+
* the cached session so the next call starts a fresh handshake (the
|
|
121
|
+
* daemon may have restarted).
|
|
122
|
+
*/
|
|
123
|
+
class DaemonConnection {
|
|
124
|
+
pluginName;
|
|
125
|
+
socketPath;
|
|
126
|
+
timeoutMs;
|
|
127
|
+
exposureProfile;
|
|
128
|
+
sessionId;
|
|
129
|
+
requestId = 0;
|
|
130
|
+
/**
|
|
131
|
+
* In-flight handshake, memoized so concurrent first-callers share one
|
|
132
|
+
* `initialize` round-trip instead of each racing their own. Cleared
|
|
133
|
+
* (success or failure) once the handshake settles, so a later call that
|
|
134
|
+
* finds `sessionId` unset — because the handshake failed, or because a
|
|
135
|
+
* broken response later dropped the session — starts a fresh one rather
|
|
136
|
+
* than piling onto a promise that has already resolved or rejected.
|
|
137
|
+
*/
|
|
138
|
+
initializing;
|
|
139
|
+
constructor(pluginName, socketPath, timeoutMs, exposureProfile) {
|
|
140
|
+
this.pluginName = pluginName;
|
|
141
|
+
this.socketPath = socketPath;
|
|
142
|
+
this.timeoutMs = timeoutMs;
|
|
143
|
+
this.exposureProfile = exposureProfile;
|
|
144
|
+
}
|
|
145
|
+
async listTools() {
|
|
146
|
+
await this.ensureInitialized();
|
|
147
|
+
const envelope = await this.rpc("tools/list");
|
|
148
|
+
if (envelope.error) {
|
|
149
|
+
throw new ShimDaemonError(this.pluginName, `daemon tools/list failed: ${envelope.error.message}`);
|
|
150
|
+
}
|
|
151
|
+
const tools = envelope.result?.tools;
|
|
152
|
+
return tools ?? [];
|
|
153
|
+
}
|
|
154
|
+
/** Returns the daemon's raw JSON-RPC `result` for `tools/call`, untouched. */
|
|
155
|
+
async callTool(toolName, args) {
|
|
156
|
+
await this.ensureInitialized();
|
|
157
|
+
const envelope = await this.rpc("tools/call", { name: toolName, arguments: args });
|
|
158
|
+
if (envelope.error) {
|
|
159
|
+
throw new ShimDaemonError(this.pluginName, `daemon tools/call '${toolName}' failed: ${envelope.error.message}`);
|
|
160
|
+
}
|
|
161
|
+
return envelope.result;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Single-flight: if a handshake is already in progress, every concurrent
|
|
165
|
+
* caller awaits that same promise instead of firing its own `initialize`
|
|
166
|
+
* request against the daemon.
|
|
167
|
+
*/
|
|
168
|
+
async ensureInitialized() {
|
|
169
|
+
if (this.sessionId)
|
|
170
|
+
return;
|
|
171
|
+
if (!this.initializing) {
|
|
172
|
+
this.initializing = this.handshake().finally(() => {
|
|
173
|
+
this.initializing = undefined;
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
return this.initializing;
|
|
177
|
+
}
|
|
178
|
+
async handshake() {
|
|
179
|
+
const response = await this.request("initialize", {
|
|
180
|
+
protocolVersion: LATEST_PROTOCOL_VERSION,
|
|
181
|
+
capabilities: {},
|
|
182
|
+
clientInfo: { name: "prism-mcp-shim", version: "0.1.0" },
|
|
183
|
+
});
|
|
184
|
+
const sessionId = response.headers.get("mcp-session-id");
|
|
185
|
+
if (!sessionId) {
|
|
186
|
+
throw new ShimDaemonError(this.pluginName, "daemon initialize did not return mcp-session-id");
|
|
187
|
+
}
|
|
188
|
+
const body = await this.parseBody(response);
|
|
189
|
+
if (body.error) {
|
|
190
|
+
throw new ShimDaemonError(this.pluginName, `daemon initialize failed: ${body.error.message}`);
|
|
191
|
+
}
|
|
192
|
+
this.sessionId = sessionId;
|
|
193
|
+
}
|
|
194
|
+
async rpc(method, params) {
|
|
195
|
+
const response = await this.request(method, params);
|
|
196
|
+
return this.parseBody(response);
|
|
197
|
+
}
|
|
198
|
+
/**
|
|
199
|
+
* Parses a daemon response body as the JSON-RPC envelope. A daemon that
|
|
200
|
+
* answers HTTP 200 with a truncated or otherwise non-JSON body is just as
|
|
201
|
+
* wedged as one that never answered: the malformed body is wrapped as a
|
|
202
|
+
* `ShimDaemonError` (never a raw `SyntaxError` escaping uncaught and
|
|
203
|
+
* mis-classified as `InternalError` by the caller) and the cached session
|
|
204
|
+
* is dropped, so the next call re-handshakes instead of replaying against
|
|
205
|
+
* a connection the daemon has already shown is broken.
|
|
206
|
+
*/
|
|
207
|
+
async parseBody(response) {
|
|
208
|
+
const text = await response.text();
|
|
209
|
+
if (text.length === 0)
|
|
210
|
+
return {};
|
|
211
|
+
try {
|
|
212
|
+
return JSON.parse(text);
|
|
213
|
+
}
|
|
214
|
+
catch (error) {
|
|
215
|
+
this.sessionId = undefined;
|
|
216
|
+
throw new ShimDaemonError(this.pluginName, `daemon returned a malformed response body: ${errorMessage(error)}`, error);
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Fires one JSON-RPC request at the daemon's UDS socket. Any failure —
|
|
221
|
+
* connection refused, socket file gone, timeout, malformed response — is
|
|
222
|
+
* wrapped as `ShimDaemonError` and drops the cached session, so a dead
|
|
223
|
+
* daemon never leaves this connection wedged on a session id that will
|
|
224
|
+
* never work again.
|
|
225
|
+
*/
|
|
226
|
+
async request(method, params) {
|
|
227
|
+
const id = (this.requestId += 1);
|
|
228
|
+
const headers = {
|
|
229
|
+
accept: "application/json, text/event-stream",
|
|
230
|
+
"content-type": "application/json",
|
|
231
|
+
"mcp-protocol-version": LATEST_PROTOCOL_VERSION,
|
|
232
|
+
};
|
|
233
|
+
if (this.sessionId)
|
|
234
|
+
headers["mcp-session-id"] = this.sessionId;
|
|
235
|
+
if (this.exposureProfile)
|
|
236
|
+
headers["x-prism-mcp-exposure"] = this.exposureProfile;
|
|
237
|
+
const controller = new AbortController();
|
|
238
|
+
const timeout = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
239
|
+
try {
|
|
240
|
+
const init = {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers,
|
|
243
|
+
body: JSON.stringify({ jsonrpc: "2.0", id, method, params }),
|
|
244
|
+
signal: controller.signal,
|
|
245
|
+
unix: this.socketPath,
|
|
246
|
+
};
|
|
247
|
+
const response = await fetch("http://localhost/mcp", init);
|
|
248
|
+
if (!response.ok) {
|
|
249
|
+
this.sessionId = undefined;
|
|
250
|
+
throw new ShimDaemonError(this.pluginName, `daemon '${method}' returned HTTP ${response.status}`);
|
|
251
|
+
}
|
|
252
|
+
return response;
|
|
253
|
+
}
|
|
254
|
+
catch (error) {
|
|
255
|
+
this.sessionId = undefined;
|
|
256
|
+
if (error instanceof ShimDaemonError)
|
|
257
|
+
throw error;
|
|
258
|
+
throw new ShimDaemonError(this.pluginName, `daemon '${method}' unreachable: ${errorMessage(error)}`, error);
|
|
259
|
+
}
|
|
260
|
+
finally {
|
|
261
|
+
clearTimeout(timeout);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
|
|
266
|
+
export const DEFAULT_SHIM_DAEMON_TIMEOUT_MS = 30_000;
|
|
267
|
+
export class ShimAggregator {
|
|
268
|
+
plugins;
|
|
269
|
+
daemonTimeoutMs;
|
|
270
|
+
getDaemon;
|
|
271
|
+
spawnTimeoutMs;
|
|
272
|
+
resolveOrSpawn;
|
|
273
|
+
enabledTools;
|
|
274
|
+
exposureProfile;
|
|
275
|
+
connections = new Map();
|
|
276
|
+
constructor(options) {
|
|
277
|
+
this.plugins = options.plugins;
|
|
278
|
+
this.daemonTimeoutMs = options.daemonTimeoutMs ?? DEFAULT_SHIM_DAEMON_TIMEOUT_MS;
|
|
279
|
+
this.getDaemon = options.getDaemon ?? getDaemonDefault;
|
|
280
|
+
this.spawnTimeoutMs = options.spawnTimeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS;
|
|
281
|
+
this.enabledTools = options.enabledTools;
|
|
282
|
+
this.exposureProfile = options.exposureProfile;
|
|
283
|
+
this.resolveOrSpawn =
|
|
284
|
+
options.resolveOrSpawn ??
|
|
285
|
+
((plugin) => resolveOrSpawnDaemon({
|
|
286
|
+
plugin,
|
|
287
|
+
prismHome: options.prismHome,
|
|
288
|
+
getDaemon: this.getDaemon,
|
|
289
|
+
spawnTimeoutMs: this.spawnTimeoutMs,
|
|
290
|
+
}));
|
|
291
|
+
}
|
|
292
|
+
/**
|
|
293
|
+
* Resolves (or reuses) the connection for `plugin`: registry record
|
|
294
|
+
* present and live and fresh -> reuse; absent, dead, or stale (bundle
|
|
295
|
+
* hash no longer matches the on-disk bundle) -> `resolveOrSpawn` spawns a
|
|
296
|
+
* fresh daemon and waits for it to come up (see `daemon-resolver.ts`).
|
|
297
|
+
*
|
|
298
|
+
* Returns `undefined` if resolution ultimately fails (no bundle to spawn,
|
|
299
|
+
* or the daemon never became live within the timeout) — that is
|
|
300
|
+
* "unavailable", not fatal: the caller omits its tools / reports a typed
|
|
301
|
+
* error, but the aggregator itself never throws for one bad plugin.
|
|
302
|
+
*/
|
|
303
|
+
async resolveConnection(plugin) {
|
|
304
|
+
let entry;
|
|
305
|
+
try {
|
|
306
|
+
entry = await this.resolveOrSpawn(plugin);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
this.connections.delete(plugin);
|
|
310
|
+
const detail = error instanceof DaemonResolveError || error instanceof Error ? error.message : String(error);
|
|
311
|
+
console.error(`[prism-mcp-shim] ${plugin}: ${detail}`);
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
const cached = this.connections.get(plugin);
|
|
315
|
+
if (cached && cached.sock === entry.sock) {
|
|
316
|
+
return cached.connection;
|
|
317
|
+
}
|
|
318
|
+
const connection = new DaemonConnection(plugin, entry.sock, this.daemonTimeoutMs, this.exposureProfile);
|
|
319
|
+
this.connections.set(plugin, { sock: entry.sock, connection });
|
|
320
|
+
return connection;
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Merged, namespaced `tools/list` across every configured plugin.
|
|
324
|
+
* Per-plugin fetches run in parallel (one slow/dead plugin never blocks
|
|
325
|
+
* another), but results are reassembled in the fixed configured-plugin
|
|
326
|
+
* order afterward, so the merged list is deterministic regardless of
|
|
327
|
+
* which daemon happened to answer first.
|
|
328
|
+
*
|
|
329
|
+
* Tools are filtered by `enabledTools` if set: only tools in that set are
|
|
330
|
+
* returned. This is the shim-side filtering applied before any daemon
|
|
331
|
+
* response is merged.
|
|
332
|
+
*/
|
|
333
|
+
async listTools() {
|
|
334
|
+
const perPlugin = await Promise.all(this.plugins.map(async (plugin) => {
|
|
335
|
+
try {
|
|
336
|
+
const connection = await this.resolveConnection(plugin);
|
|
337
|
+
if (!connection)
|
|
338
|
+
return [];
|
|
339
|
+
const tools = await connection.listTools();
|
|
340
|
+
return tools.map((tool) => ({ plugin, tool }));
|
|
341
|
+
}
|
|
342
|
+
catch {
|
|
343
|
+
// Unavailable for this pass; omitted, never fatal to the merge.
|
|
344
|
+
return [];
|
|
345
|
+
}
|
|
346
|
+
}));
|
|
347
|
+
const merged = [];
|
|
348
|
+
for (const named of perPlugin) {
|
|
349
|
+
for (const { plugin, tool } of named) {
|
|
350
|
+
const fqName = namespacedToolName(plugin, tool.name);
|
|
351
|
+
// Filter by enabledTools if set
|
|
352
|
+
if (this.enabledTools !== undefined && !this.enabledTools.has(fqName)) {
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
merged.push({ ...tool, name: fqName });
|
|
356
|
+
}
|
|
357
|
+
}
|
|
358
|
+
return merged;
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Dispatches a namespaced tool name to its owning plugin's daemon and
|
|
362
|
+
* returns the daemon's raw `CallToolResult` untouched. Throws
|
|
363
|
+
* `ShimDaemonError` (typed) when the name cannot be resolved to a
|
|
364
|
+
* configured plugin, when that plugin's daemon is absent/unreachable
|
|
365
|
+
* within the timeout, or when the tool is not in the enabled set —
|
|
366
|
+
* either way, this never crashes the shim and never touches any other
|
|
367
|
+
* plugin's connection.
|
|
368
|
+
*/
|
|
369
|
+
async callTool(fqName, args) {
|
|
370
|
+
// Filter by enabledTools if set
|
|
371
|
+
if (this.enabledTools !== undefined && !this.enabledTools.has(fqName)) {
|
|
372
|
+
throw new ShimDaemonError("shim", `tool name '${fqName}' is not enabled`);
|
|
373
|
+
}
|
|
374
|
+
const split = splitNamespacedToolName(fqName);
|
|
375
|
+
if (!split) {
|
|
376
|
+
throw new ShimDaemonError("shim", `tool name '${fqName}' is not a namespaced shim tool`);
|
|
377
|
+
}
|
|
378
|
+
const plugin = this.plugins.find((candidate) => pluginWireNamespace(candidate) === split.namespace);
|
|
379
|
+
if (!plugin) {
|
|
380
|
+
throw new ShimDaemonError("shim", `tool name '${fqName}' does not match any configured plugin`);
|
|
381
|
+
}
|
|
382
|
+
const connection = await this.resolveConnection(plugin);
|
|
383
|
+
if (!connection) {
|
|
384
|
+
throw new ShimDaemonError(plugin, `plugin has no live daemon registered`);
|
|
385
|
+
}
|
|
386
|
+
return connection.callTool(split.toolName, args);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// Harness-facing stdio MCP server
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
const SERVER_NAME = "prism-mcp-shim";
|
|
393
|
+
const SERVER_VERSION = "0.1.0";
|
|
394
|
+
export const createShimServer = (aggregator) => {
|
|
395
|
+
const server = new Server({ name: SERVER_NAME, version: SERVER_VERSION }, { capabilities: { tools: {} } });
|
|
396
|
+
server.setRequestHandler(ListToolsRequestSchema, async () => ({
|
|
397
|
+
tools: await aggregator.listTools(),
|
|
398
|
+
}));
|
|
399
|
+
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
400
|
+
const { name, arguments: args } = request.params;
|
|
401
|
+
try {
|
|
402
|
+
const result = await aggregator.callTool(name, args ?? {});
|
|
403
|
+
return result;
|
|
404
|
+
}
|
|
405
|
+
catch (error) {
|
|
406
|
+
if (error instanceof ShimDaemonError) {
|
|
407
|
+
throw new McpError(ErrorCode.ConnectionClosed, error.message);
|
|
408
|
+
}
|
|
409
|
+
throw new McpError(ErrorCode.InternalError, errorMessage(error));
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
return server;
|
|
413
|
+
};
|
|
414
|
+
/**
|
|
415
|
+
* Wires the aggregator to a stdio transport and runs until stdin closes or
|
|
416
|
+
* the process receives SIGINT/SIGTERM. Never throws for daemon-level
|
|
417
|
+
* failures — those surface per-request as typed MCP errors instead.
|
|
418
|
+
*/
|
|
419
|
+
export const runShim = async (options) => {
|
|
420
|
+
const aggregator = new ShimAggregator(options);
|
|
421
|
+
const server = createShimServer(aggregator);
|
|
422
|
+
const transport = new StdioServerTransport();
|
|
423
|
+
const shutdown = async () => {
|
|
424
|
+
try {
|
|
425
|
+
await server.close();
|
|
426
|
+
}
|
|
427
|
+
finally {
|
|
428
|
+
process.exit(0);
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
process.on("SIGINT", () => void shutdown());
|
|
432
|
+
process.on("SIGTERM", () => void shutdown());
|
|
433
|
+
await server.connect(transport);
|
|
434
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error type for UDS path violations.
|
|
3
|
+
* Typed to distinguish from other path errors.
|
|
4
|
+
*/
|
|
5
|
+
export declare class UDSPathLengthError extends Error {
|
|
6
|
+
readonly attemptedPath: string;
|
|
7
|
+
readonly kind: "uds-path-length-error";
|
|
8
|
+
constructor(message: string, attemptedPath: string);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Construct a content-addressed UDS socket path.
|
|
12
|
+
*
|
|
13
|
+
* Returns: `<prismHome>/runtime/mcp/<plugin>/<truncated-hash>.sock`
|
|
14
|
+
*
|
|
15
|
+
* @param plugin - Plugin identifier (must match /^[a-zA-Z0-9_-]+$/)
|
|
16
|
+
* @param bundleHash - Full content hash; will be truncated to first 16 hex
|
|
17
|
+
* @param prismHome - Prism home directory. Threaded explicitly by the
|
|
18
|
+
* caller (the CLI edge resolves `PRISM_HOME` once via `resolvePrismHome()`
|
|
19
|
+
* and passes the result down); this package cannot import that resolver
|
|
20
|
+
* itself (`packages/prism-sdk` has no dependency on the root `src/`
|
|
21
|
+
* tree), so an omitted value falls back to the same default
|
|
22
|
+
* (`~/.prism`) `resolvePrismHome()` uses when unset.
|
|
23
|
+
* @returns Absolute path to the socket file
|
|
24
|
+
* @throws UDSPathLengthError if resulting path exceeds 100 bytes
|
|
25
|
+
*/
|
|
26
|
+
export declare function udsPathFor(plugin: string, bundleHash: string, prismHome?: string): string;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { homedir } from "os";
|
|
2
|
+
/**
|
|
3
|
+
* Error type for UDS path violations.
|
|
4
|
+
* Typed to distinguish from other path errors.
|
|
5
|
+
*/
|
|
6
|
+
export class UDSPathLengthError extends Error {
|
|
7
|
+
attemptedPath;
|
|
8
|
+
kind = "uds-path-length-error";
|
|
9
|
+
constructor(message, attemptedPath) {
|
|
10
|
+
super(message);
|
|
11
|
+
this.attemptedPath = attemptedPath;
|
|
12
|
+
this.name = "UDSPathLengthError";
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* macOS sun_path limit (sockaddr_un.sun_path)
|
|
17
|
+
*/
|
|
18
|
+
const MACOS_SUN_PATH_LIMIT = 104;
|
|
19
|
+
/**
|
|
20
|
+
* Tolerance below the hard limit to account for null terminator and safety margin.
|
|
21
|
+
* Standard practice: 104 - 4 bytes = 100 bytes.
|
|
22
|
+
*/
|
|
23
|
+
const PATH_LENGTH_LIMIT = 100;
|
|
24
|
+
/**
|
|
25
|
+
* Regex to validate plugin names: alphanumeric, dash, underscore only.
|
|
26
|
+
* Prevents path traversal and special chars that could break socket path integrity.
|
|
27
|
+
*/
|
|
28
|
+
const VALID_PLUGIN_NAME = /^[a-zA-Z0-9_-]+$/;
|
|
29
|
+
/**
|
|
30
|
+
* Truncate hash to first 16 hex characters for path length safety.
|
|
31
|
+
* 16 hex chars = 64 bits, sufficient for disambiguation.
|
|
32
|
+
*/
|
|
33
|
+
function truncateHash(bundleHash) {
|
|
34
|
+
return bundleHash.slice(0, 16);
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Construct a content-addressed UDS socket path.
|
|
38
|
+
*
|
|
39
|
+
* Returns: `<prismHome>/runtime/mcp/<plugin>/<truncated-hash>.sock`
|
|
40
|
+
*
|
|
41
|
+
* @param plugin - Plugin identifier (must match /^[a-zA-Z0-9_-]+$/)
|
|
42
|
+
* @param bundleHash - Full content hash; will be truncated to first 16 hex
|
|
43
|
+
* @param prismHome - Prism home directory. Threaded explicitly by the
|
|
44
|
+
* caller (the CLI edge resolves `PRISM_HOME` once via `resolvePrismHome()`
|
|
45
|
+
* and passes the result down); this package cannot import that resolver
|
|
46
|
+
* itself (`packages/prism-sdk` has no dependency on the root `src/`
|
|
47
|
+
* tree), so an omitted value falls back to the same default
|
|
48
|
+
* (`~/.prism`) `resolvePrismHome()` uses when unset.
|
|
49
|
+
* @returns Absolute path to the socket file
|
|
50
|
+
* @throws UDSPathLengthError if resulting path exceeds 100 bytes
|
|
51
|
+
*/
|
|
52
|
+
export function udsPathFor(plugin, bundleHash, prismHome) {
|
|
53
|
+
// Validate plugin name
|
|
54
|
+
if (!VALID_PLUGIN_NAME.test(plugin)) {
|
|
55
|
+
throw new Error(`Invalid plugin name: '${plugin}'. Must match /^[a-zA-Z0-9_-]+$/`);
|
|
56
|
+
}
|
|
57
|
+
// Truncate hash to first 16 hex characters
|
|
58
|
+
const truncated = truncateHash(bundleHash);
|
|
59
|
+
// Build the path
|
|
60
|
+
const home = prismHome ?? `${homedir()}/.prism`;
|
|
61
|
+
const relativePath = `runtime/mcp/${plugin}/${truncated}.sock`;
|
|
62
|
+
const fullPath = `${home}/${relativePath}`;
|
|
63
|
+
// Assert length constraint
|
|
64
|
+
const pathLength = Buffer.byteLength(fullPath, "utf8");
|
|
65
|
+
if (pathLength > PATH_LENGTH_LIMIT) {
|
|
66
|
+
throw new UDSPathLengthError(`UDS socket path exceeds ${PATH_LENGTH_LIMIT}-byte limit (got ${pathLength} bytes). ` +
|
|
67
|
+
`Plugin name '${plugin}' may be too long.`, fullPath);
|
|
68
|
+
}
|
|
69
|
+
return fullPath;
|
|
70
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Error type for registry operation violations.
|
|
3
|
+
* Used to distinguish registry errors from other file system errors.
|
|
4
|
+
*/
|
|
5
|
+
export declare class UDSRegistryError extends Error {
|
|
6
|
+
readonly context?: unknown | undefined;
|
|
7
|
+
readonly kind: "uds-registry-error";
|
|
8
|
+
constructor(message: string, context?: unknown | undefined);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Per-plugin daemon instance record.
|
|
12
|
+
*/
|
|
13
|
+
export interface RegistryEntry {
|
|
14
|
+
readonly pid: number;
|
|
15
|
+
readonly sock: string;
|
|
16
|
+
readonly bundleHash: string;
|
|
17
|
+
readonly startedAt: number;
|
|
18
|
+
readonly lastUsed: number;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Registry of active daemon instances, indexed by plugin name.
|
|
22
|
+
*/
|
|
23
|
+
export type RegistryData = Record<string, RegistryEntry>;
|
|
24
|
+
/**
|
|
25
|
+
* Result type for registry operations that may fail gracefully.
|
|
26
|
+
* Success carries the result; absent means the entry does not exist or is corrupted.
|
|
27
|
+
*/
|
|
28
|
+
export type RegistryResult<T> = {
|
|
29
|
+
kind: "ok";
|
|
30
|
+
value: T;
|
|
31
|
+
} | {
|
|
32
|
+
kind: "absent";
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Register a new daemon instance or update an existing one.
|
|
36
|
+
*
|
|
37
|
+
* Atomically writes the entry to this plugin's own registry file. Never
|
|
38
|
+
* reads or rewrites any other plugin's file.
|
|
39
|
+
*
|
|
40
|
+
* Throws UDSRegistryError on I/O failure (unrecoverable).
|
|
41
|
+
*/
|
|
42
|
+
export declare function registerDaemon(plugin: string, entry: RegistryEntry): Promise<void>;
|
|
43
|
+
/**
|
|
44
|
+
* Unregister a daemon instance by plugin name.
|
|
45
|
+
*
|
|
46
|
+
* Removes this plugin's registry file. Does nothing (no error) if the
|
|
47
|
+
* entry does not exist.
|
|
48
|
+
*
|
|
49
|
+
* Throws UDSRegistryError on I/O failure (unrecoverable), file-not-found excepted.
|
|
50
|
+
*/
|
|
51
|
+
export declare function unregisterDaemon(plugin: string): Promise<void>;
|
|
52
|
+
/**
|
|
53
|
+
* Get a single daemon entry by plugin name.
|
|
54
|
+
*
|
|
55
|
+
* Returns `{ kind: "ok", value: entry }` on success.
|
|
56
|
+
* Returns `{ kind: "absent" }` if the plugin is not registered or
|
|
57
|
+
* the registry is corrupted.
|
|
58
|
+
*
|
|
59
|
+
* Never throws.
|
|
60
|
+
*/
|
|
61
|
+
export declare function getDaemon(plugin: string): Promise<RegistryResult<RegistryEntry>>;
|
|
62
|
+
/**
|
|
63
|
+
* Get all registered daemon entries across every plugin.
|
|
64
|
+
*
|
|
65
|
+
* Returns `{ kind: "ok", value: data }` on success (may be empty).
|
|
66
|
+
* Returns `{ kind: "absent" }` if the registry root directory does not
|
|
67
|
+
* exist yet (nothing has ever registered).
|
|
68
|
+
*
|
|
69
|
+
* Never throws; unreadable or corrupted per-plugin files are skipped
|
|
70
|
+
* individually rather than failing the whole scan.
|
|
71
|
+
*/
|
|
72
|
+
export declare function getAllDaemons(): Promise<RegistryResult<RegistryData>>;
|
|
73
|
+
/**
|
|
74
|
+
* Update the lastUsed timestamp for a daemon entry.
|
|
75
|
+
*
|
|
76
|
+
* Returns `{ kind: "ok" }` on success.
|
|
77
|
+
* Returns `{ kind: "absent" }` if the plugin is not registered.
|
|
78
|
+
*
|
|
79
|
+
* Throws UDSRegistryError on I/O failure (unrecoverable).
|
|
80
|
+
*/
|
|
81
|
+
export declare function touchDaemon(plugin: string): Promise<RegistryResult<void>>;
|
|
82
|
+
/**
|
|
83
|
+
* Outcome of `cleanupDaemonIfOwner`.
|
|
84
|
+
*
|
|
85
|
+
* - "cleaned": the record belonged to the given pid; it was unregistered
|
|
86
|
+
* and the socket file (if provided) was unlinked.
|
|
87
|
+
* - "not-owner": a record exists but names a *different* pid — a successor
|
|
88
|
+
* that has already re-registered under the same plugin key. Nothing was
|
|
89
|
+
* touched.
|
|
90
|
+
* - "absent": no record exists for this plugin. Treated as "not ours":
|
|
91
|
+
* nothing was touched.
|
|
92
|
+
*/
|
|
93
|
+
export type OwnershipCleanupResult = "cleaned" | "not-owner" | "absent";
|
|
94
|
+
/**
|
|
95
|
+
* Ownership-gated teardown for a daemon's registration and socket file.
|
|
96
|
+
*
|
|
97
|
+
* Re-reads the current registry record for `plugin` and only acts — unlink
|
|
98
|
+
* `socketPath` and remove the registry entry — when that record's pid
|
|
99
|
+
* matches `pid`, i.e. when the caller is still the process the registry
|
|
100
|
+
* currently considers live for that plugin.
|
|
101
|
+
*
|
|
102
|
+
* This exists to protect a fast-respawning successor: if a predecessor is
|
|
103
|
+
* slow to drain and only calls this after a successor has already bound
|
|
104
|
+
* the same socket path and re-registered under the same plugin key, the
|
|
105
|
+
* registry record now names the successor's pid — a missing record or one
|
|
106
|
+
* naming a different pid means "not mine to clean up", so the successor's
|
|
107
|
+
* live socket file and registration are left completely untouched.
|
|
108
|
+
*/
|
|
109
|
+
export declare function cleanupDaemonIfOwner(plugin: string, pid: number, socketPath: string | undefined): Promise<OwnershipCleanupResult>;
|