@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.
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Resolve-or-spawn: given a plugin name, produce a *live* daemon registry
3
+ * entry to connect to -- spawning a fresh daemon when the registry has no
4
+ * entry, the recorded entry is dead, or the recorded entry's bundle hash no
5
+ * longer matches the compiled bundle on disk (a `prism refresh` ran since
6
+ * that daemon started).
7
+ *
8
+ * Builds entirely on the wave-0 substrate rather than re-implementing any of
9
+ * it:
10
+ * - `getDaemon` / the per-plugin registry file (`uds-registry.ts`).
11
+ * - `udsPathFor`, the content-addressed socket path (`uds-path.ts`) --
12
+ * a different bundle hash always yields a different socket path, so a
13
+ * stale daemon (old hash) and a fresh one (new hash) never collide on the
14
+ * same file.
15
+ * - `probeSocketLiveness` (`uds-singleton.ts`).
16
+ *
17
+ * This module deliberately never binds a socket or acquires the bind lock
18
+ * itself. Every compiled bundle's own runtime already calls
19
+ * `bindUnixSocketSingleton` on startup (see `src/compile/mcp-bundle.ts`'s
20
+ * `MCP_SDK_HTTP_RUNTIME`), and *that* is what guarantees exactly one winner
21
+ * when N callers race to spawn the same plugin: this module just spawns
22
+ * (possibly redundantly) and waits for a live registry entry to show up.
23
+ * Losing spawns detect "already-served" inside their own bundle and
24
+ * `process.exit(0)` before ever registering.
25
+ */
26
+ import { spawn } from "node:child_process";
27
+ import { createHash } from "node:crypto";
28
+ import { readFile } from "node:fs/promises";
29
+ import { dirname, join } from "node:path";
30
+ import { udsPathFor as udsPathForDefault } from "./uds-path.js";
31
+ import { getDaemon as getDaemonDefault } from "./uds-registry.js";
32
+ import { probeSocketLiveness as probeSocketLivenessDefault } from "./uds-singleton.js";
33
+ export class DaemonResolveError extends Error {
34
+ pluginName;
35
+ cause;
36
+ kind = "daemon-resolve-error";
37
+ constructor(pluginName, message, cause) {
38
+ super(`[${pluginName}] ${message}`);
39
+ this.pluginName = pluginName;
40
+ this.cause = cause;
41
+ this.name = "DaemonResolveError";
42
+ }
43
+ }
44
+ const errorMessage = (error) => (error instanceof Error ? error.message : String(error));
45
+ const delay = (ms) => new Promise((resolveDelay) => setTimeout(resolveDelay, ms));
46
+ export const DEFAULT_SPAWN_TIMEOUT_MS = 15_000;
47
+ const DEFAULT_POLL_INTERVAL_MS = 50;
48
+ /**
49
+ * `<prismHome>/runtime/mcp/<plugin>` -- the per-plugin runtime directory
50
+ * that owns this plugin's registry file, socket file(s), and compiled
51
+ * bundle. Derived from `udsPathFor` (any hash value yields the same parent
52
+ * directory) rather than re-deriving the layout independently.
53
+ *
54
+ * `prismHome` is threaded explicitly (never read from the environment
55
+ * here): the CLI edge resolves `PRISM_HOME` once via `resolvePrismHome()`
56
+ * and passes the result down. An omitted value falls back to the same
57
+ * `~/.prism` default `resolvePrismHome()` uses when unset -- `prism-sdk`
58
+ * cannot import that resolver itself (no dependency on the root `src/`
59
+ * tree).
60
+ */
61
+ export const pluginRuntimeDir = (plugin, prismHome) => dirname(udsPathForDefault(plugin, "", prismHome));
62
+ /**
63
+ * `<prismHome>/runtime/mcp/<plugin>/server.mjs` -- the canonical compiled
64
+ * bundle a `prism refresh` run produces. This module only ever reads it; it
65
+ * never writes or rebuilds it.
66
+ */
67
+ export const pluginBundlePath = (plugin, prismHome) => join(pluginRuntimeDir(plugin, prismHome), "server.mjs");
68
+ const sha256Hex = (content) => createHash("sha256").update(content).digest("hex");
69
+ const defaultHashBundle = async (bundlePath) => sha256Hex(await readFile(bundlePath));
70
+ /**
71
+ * Spawns `bun <bundle>` detached (so it outlives this process) with the UDS
72
+ * + registry identity env the bundle's own runtime reads at startup (see
73
+ * `PRISM_MCP_UDS_PATH` / `PRISM_MCP_REGISTRY_PLUGIN_NAME` /
74
+ * `PRISM_MCP_REGISTRY_BUNDLE_HASH` in `MCP_SDK_HTTP_RUNTIME`). Never waits
75
+ * on the child and never lets a spawn-level error (e.g. `bun` missing from
76
+ * PATH) throw unhandled -- resolution simply times out and reports a typed
77
+ * `DaemonResolveError` instead.
78
+ */
79
+ const defaultSpawnDaemon = ({ plugin, bundlePath, udsPath, bundleHash }) => {
80
+ const child = spawn("bun", [bundlePath], {
81
+ cwd: dirname(bundlePath),
82
+ env: {
83
+ ...process.env,
84
+ PRISM_MCP_UDS_PATH: udsPath,
85
+ PRISM_MCP_REGISTRY_PLUGIN_NAME: plugin,
86
+ PRISM_MCP_REGISTRY_BUNDLE_HASH: bundleHash,
87
+ },
88
+ detached: true,
89
+ stdio: "ignore",
90
+ });
91
+ child.on("error", () => undefined);
92
+ child.unref();
93
+ };
94
+ /**
95
+ * Returns `true` when the bundle on disk no longer matches
96
+ * `registeredHash`. Locating the bundle (`bundlePathFor`, which by default
97
+ * derives from `udsPathFor` and so can throw `UDSPathLengthError` for a
98
+ * plugin/home combination that would not fit a UDS socket path either) or
99
+ * reading it (deleted, or -- in a hermetic test -- never written) are both
100
+ * treated as "unknown, not proven stale": a live daemon is strictly better
101
+ * than none, so failing to independently re-derive where a *hypothetical
102
+ * replacement* bundle would live never forces a respawn of an
103
+ * otherwise-healthy, already-reachable daemon.
104
+ */
105
+ const isBundleStale = async (options) => {
106
+ try {
107
+ const bundlePath = options.bundlePathFor(options.plugin);
108
+ return (await options.hashBundle(bundlePath)) !== options.registeredHash;
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ };
114
+ const waitForLiveEntry = async (options) => {
115
+ const { plugin, udsPath, getDaemon, probeSocketLiveness, spawnTimeoutMs, pollIntervalMs } = options;
116
+ const deadline = Date.now() + spawnTimeoutMs;
117
+ while (true) {
118
+ const registered = await getDaemon(plugin);
119
+ if (registered.kind === "ok" && registered.value.sock === udsPath) {
120
+ const liveness = await probeSocketLiveness(registered.value.sock);
121
+ if (liveness === "live")
122
+ return registered.value;
123
+ }
124
+ if (Date.now() >= deadline) {
125
+ throw new DaemonResolveError(plugin, `spawned daemon did not become live at ${udsPath} within ${spawnTimeoutMs}ms`);
126
+ }
127
+ await delay(pollIntervalMs);
128
+ }
129
+ };
130
+ /**
131
+ * Resolves the live registry entry to connect to for `plugin`:
132
+ *
133
+ * registry entry present -> liveness probe
134
+ * live & bundle hash matches on-disk bundle -> use it
135
+ * dead, OR bundle hash stale -> spawn fresh
136
+ * registry entry absent -> spawn fresh
137
+ *
138
+ * "Spawn fresh" computes the bundle's current content hash, derives its
139
+ * content-addressed socket path via `udsPathFor`, launches the daemon, and
140
+ * polls the registry + a liveness probe until a live entry at that exact
141
+ * socket path appears or `spawnTimeoutMs` elapses.
142
+ *
143
+ * Throws `DaemonResolveError` when the bundle cannot be read (nothing to
144
+ * spawn) or the spawned daemon never becomes live in time.
145
+ */
146
+ export const resolveOrSpawnDaemon = async (options) => {
147
+ const { plugin } = options;
148
+ const spawnTimeoutMs = options.spawnTimeoutMs ?? DEFAULT_SPAWN_TIMEOUT_MS;
149
+ const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
150
+ const getDaemon = options.getDaemon ?? getDaemonDefault;
151
+ const probeSocketLiveness = options.probeSocketLiveness ?? probeSocketLivenessDefault;
152
+ const bundlePathFor = options.bundlePathFor ?? ((plugin) => pluginBundlePath(plugin, options.prismHome));
153
+ const buildUdsPath = options.udsPathFor ?? ((plugin, bundleHash) => udsPathForDefault(plugin, bundleHash, options.prismHome));
154
+ const hashBundle = options.hashBundle ?? defaultHashBundle;
155
+ const spawnDaemon = options.spawnDaemon ?? defaultSpawnDaemon;
156
+ const registered = await getDaemon(plugin);
157
+ if (registered.kind === "ok") {
158
+ const liveness = await probeSocketLiveness(registered.value.sock);
159
+ if (liveness === "live") {
160
+ const stale = await isBundleStale({
161
+ plugin,
162
+ registeredHash: registered.value.bundleHash,
163
+ bundlePathFor,
164
+ hashBundle,
165
+ });
166
+ if (!stale)
167
+ return registered.value;
168
+ }
169
+ }
170
+ // Reaching here means we are actually about to spawn, so -- unlike the
171
+ // staleness check above -- locating and reading the bundle now has to
172
+ // succeed; there is nothing to fall back to.
173
+ let bundlePath;
174
+ let currentHash;
175
+ try {
176
+ bundlePath = bundlePathFor(plugin);
177
+ currentHash = await hashBundle(bundlePath);
178
+ }
179
+ catch (error) {
180
+ throw new DaemonResolveError(plugin, `cannot spawn: unable to locate/read compiled bundle: ${errorMessage(error)}`, error);
181
+ }
182
+ const udsPath = buildUdsPath(plugin, currentHash);
183
+ spawnDaemon({ plugin, bundlePath, udsPath, bundleHash: currentHash });
184
+ return waitForLiveEntry({ plugin, udsPath, getDaemon, probeSocketLiveness, spawnTimeoutMs, pollIntervalMs });
185
+ };
@@ -0,0 +1,155 @@
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 { type Tool } from "@modelcontextprotocol/sdk/types.js";
42
+ import { type RegistryResult, type RegistryEntry } from "./uds-registry.js";
43
+ /** `p_<hash8>` — see `stableHash8` above for why this exact shape. */
44
+ export declare const pluginWireNamespace: (pluginName: string) => string;
45
+ /** Harness-visible tool name: `<pluginWireNamespace>__<originalToolName>`. */
46
+ export declare const namespacedToolName: (pluginName: string, toolName: string) => string;
47
+ /**
48
+ * Inverse of `namespacedToolName`. The namespace segment has a fixed
49
+ * width, so this splits on position rather than searching for the `__`
50
+ * separator — a tool name is free-form and may itself legally contain
51
+ * `__`, but the namespace segment never does.
52
+ */
53
+ export declare const splitNamespacedToolName: (fqName: string) => {
54
+ readonly namespace: string;
55
+ readonly toolName: string;
56
+ } | undefined;
57
+ export declare class ShimDaemonError extends Error {
58
+ readonly pluginName: string;
59
+ readonly cause?: unknown | undefined;
60
+ readonly kind: "shim-daemon-error";
61
+ constructor(pluginName: string, message: string, cause?: unknown | undefined);
62
+ }
63
+ export type GetDaemonFn = (plugin: string) => Promise<RegistryResult<RegistryEntry>>;
64
+ export type ResolveOrSpawnFn = (plugin: string) => Promise<RegistryEntry>;
65
+ export interface ShimAggregatorOptions {
66
+ readonly plugins: ReadonlyArray<string>;
67
+ /**
68
+ * Prism home directory, threaded from the process entrypoint (see
69
+ * `src/mcp/shim-main.ts`, which resolves `PRISM_HOME` once via
70
+ * `resolvePrismHome()`). Forwarded to the default `resolveOrSpawn` so a
71
+ * `PRISM_HOME` override locates the same bundle/socket path `prism
72
+ * refresh` wrote, instead of always falling back to `~/.prism`. Ignored
73
+ * when `resolveOrSpawn` is supplied directly.
74
+ */
75
+ readonly prismHome?: string;
76
+ readonly daemonTimeoutMs?: number;
77
+ readonly spawnTimeoutMs?: number;
78
+ readonly getDaemon?: GetDaemonFn;
79
+ /**
80
+ * Full override of daemon resolution, bypassing `getDaemon` entirely.
81
+ * Tests that only want to exercise merge/dispatch/isolation (not
82
+ * resolve-or-spawn itself) inject a stub here instead of standing up a
83
+ * real compiled bundle on disk.
84
+ */
85
+ readonly resolveOrSpawn?: ResolveOrSpawnFn;
86
+ /** Optional set of tool names to enable; if present, only these tools are exposed. */
87
+ readonly enabledTools?: ReadonlySet<string>;
88
+ /** Optional exposure profile to pass to daemon as X-Prism-Mcp-Exposure header. */
89
+ readonly exposureProfile?: string;
90
+ }
91
+ export declare const DEFAULT_SHIM_DAEMON_TIMEOUT_MS = 30000;
92
+ export declare class ShimAggregator {
93
+ private readonly plugins;
94
+ private readonly daemonTimeoutMs;
95
+ private readonly getDaemon;
96
+ private readonly spawnTimeoutMs;
97
+ private readonly resolveOrSpawn;
98
+ private readonly enabledTools;
99
+ private readonly exposureProfile;
100
+ private readonly connections;
101
+ constructor(options: ShimAggregatorOptions);
102
+ /**
103
+ * Resolves (or reuses) the connection for `plugin`: registry record
104
+ * present and live and fresh -> reuse; absent, dead, or stale (bundle
105
+ * hash no longer matches the on-disk bundle) -> `resolveOrSpawn` spawns a
106
+ * fresh daemon and waits for it to come up (see `daemon-resolver.ts`).
107
+ *
108
+ * Returns `undefined` if resolution ultimately fails (no bundle to spawn,
109
+ * or the daemon never became live within the timeout) — that is
110
+ * "unavailable", not fatal: the caller omits its tools / reports a typed
111
+ * error, but the aggregator itself never throws for one bad plugin.
112
+ */
113
+ private resolveConnection;
114
+ /**
115
+ * Merged, namespaced `tools/list` across every configured plugin.
116
+ * Per-plugin fetches run in parallel (one slow/dead plugin never blocks
117
+ * another), but results are reassembled in the fixed configured-plugin
118
+ * order afterward, so the merged list is deterministic regardless of
119
+ * which daemon happened to answer first.
120
+ *
121
+ * Tools are filtered by `enabledTools` if set: only tools in that set are
122
+ * returned. This is the shim-side filtering applied before any daemon
123
+ * response is merged.
124
+ */
125
+ listTools(): Promise<ReadonlyArray<Tool>>;
126
+ /**
127
+ * Dispatches a namespaced tool name to its owning plugin's daemon and
128
+ * returns the daemon's raw `CallToolResult` untouched. Throws
129
+ * `ShimDaemonError` (typed) when the name cannot be resolved to a
130
+ * configured plugin, when that plugin's daemon is absent/unreachable
131
+ * within the timeout, or when the tool is not in the enabled set —
132
+ * either way, this never crashes the shim and never touches any other
133
+ * plugin's connection.
134
+ */
135
+ callTool(fqName: string, args: Record<string, unknown>): Promise<unknown>;
136
+ }
137
+ export declare const createShimServer: (aggregator: ShimAggregator) => Server;
138
+ export interface RunShimOptions {
139
+ readonly plugins: ReadonlyArray<string>;
140
+ /** Prism home directory, threaded from the process entrypoint. See `ShimAggregatorOptions.prismHome`. */
141
+ readonly prismHome?: string;
142
+ readonly daemonTimeoutMs?: number;
143
+ readonly spawnTimeoutMs?: number;
144
+ readonly getDaemon?: GetDaemonFn;
145
+ /** Optional set of tool names to enable; if present, only these tools are exposed. */
146
+ readonly enabledTools?: ReadonlySet<string>;
147
+ /** Optional exposure profile to pass to daemon as X-Prism-Mcp-Exposure header. */
148
+ readonly exposureProfile?: string;
149
+ }
150
+ /**
151
+ * Wires the aggregator to a stdio transport and runs until stdin closes or
152
+ * the process receives SIGINT/SIGTERM. Never throws for daemon-level
153
+ * failures — those surface per-request as typed MCP errors instead.
154
+ */
155
+ export declare const runShim: (options: RunShimOptions) => Promise<void>;