@george43g/mcp-kit 0.1.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/dist/dispatch.d.ts +58 -0
  4. package/dist/dispatch.d.ts.map +1 -0
  5. package/dist/dispatch.js +110 -0
  6. package/dist/dispatch.js.map +1 -0
  7. package/dist/index.d.ts +13 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +8 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/prompt-injection.d.ts +37 -0
  12. package/dist/prompt-injection.d.ts.map +1 -0
  13. package/dist/prompt-injection.js +46 -0
  14. package/dist/prompt-injection.js.map +1 -0
  15. package/dist/resources.d.ts +81 -0
  16. package/dist/resources.d.ts.map +1 -0
  17. package/dist/resources.js +66 -0
  18. package/dist/resources.js.map +1 -0
  19. package/dist/sanitize.d.ts +18 -0
  20. package/dist/sanitize.d.ts.map +1 -0
  21. package/dist/sanitize.js +33 -0
  22. package/dist/sanitize.js.map +1 -0
  23. package/dist/tool-registry.d.ts +78 -0
  24. package/dist/tool-registry.d.ts.map +1 -0
  25. package/dist/tool-registry.js +38 -0
  26. package/dist/tool-registry.js.map +1 -0
  27. package/dist/transports/http.d.ts +47 -0
  28. package/dist/transports/http.d.ts.map +1 -0
  29. package/dist/transports/http.js +150 -0
  30. package/dist/transports/http.js.map +1 -0
  31. package/dist/transports/stdio.d.ts +17 -0
  32. package/dist/transports/stdio.d.ts.map +1 -0
  33. package/dist/transports/stdio.js +53 -0
  34. package/dist/transports/stdio.js.map +1 -0
  35. package/package.json +72 -0
  36. package/src/dispatch.ts +172 -0
  37. package/src/index.ts +34 -0
  38. package/src/prompt-injection.ts +56 -0
  39. package/src/resources.ts +120 -0
  40. package/src/sanitize.ts +37 -0
  41. package/src/tool-registry.ts +111 -0
  42. package/src/transports/http.ts +188 -0
  43. package/src/transports/stdio.ts +70 -0
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ export type {
2
+ BuildDispatcherOptions,
3
+ Dispatch,
4
+ DispatcherCounters,
5
+ ToolResult,
6
+ } from "./dispatch.js";
7
+ export { buildDispatcher } from "./dispatch.js";
8
+ export {
9
+ type WrapInstructionsResult,
10
+ wrapInstructions,
11
+ wrapToolError,
12
+ wrapUntrusted,
13
+ } from "./prompt-injection.js";
14
+ export type {
15
+ BuildResourcesHandlerOptions,
16
+ ResourceContent,
17
+ ResourceHandlers,
18
+ ResourceListEntry,
19
+ ResourcesProvider,
20
+ ResourceTemplate,
21
+ } from "./resources.js";
22
+ export { buildResourcesHandler } from "./resources.js";
23
+ export { sanitize } from "./sanitize.js";
24
+ export type {
25
+ AnyToolDefinition,
26
+ ContentBlock,
27
+ ToolDefinition,
28
+ ToolRegistry,
29
+ } from "./tool-registry.js";
30
+ export { makeRegistry } from "./tool-registry.js";
31
+ export type { HttpServerHandle, HttpServerOptions } from "./transports/http.js";
32
+ export { startHttpServer } from "./transports/http.js";
33
+ export type { StartStdioOptions } from "./transports/stdio.js";
34
+ export { startStdio } from "./transports/stdio.js";
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Prompt-injection defense helpers.
3
+ *
4
+ * Two primitives:
5
+ *
6
+ * 1. `wrapUntrusted(text)` — wrap arbitrary content with `<untrusted>...
7
+ * </untrusted>` markers. Use whenever a tool returns content sourced
8
+ * from an external system (user message, scraped page, etc.). The LLM
9
+ * is trained to treat tagged content as data, not instructions.
10
+ *
11
+ * 2. `wrapInstructions(text, uuid)` — emit instructions to the LLM that
12
+ * only "fire" when the same UUID is present in the user's most recent
13
+ * message. Pattern documented in docs/GUARDRAILS_MCP_RESPONSES.md.
14
+ *
15
+ * Tool returns:
16
+ * <instructions uuid="d8a3..."> follow steps... </instructions>
17
+ *
18
+ * The LLM, having been trained on this pattern, will refuse to follow
19
+ * the embedded instructions unless the user's prior message contains
20
+ * the UUID — proving the user genuinely wanted to authorize them.
21
+ *
22
+ * Use sparingly: prefer not to embed instructions in tool responses
23
+ * at all. When you must (multi-step workflows, follow-ups), use this
24
+ * wrapper.
25
+ */
26
+
27
+ import { randomUUID } from "node:crypto";
28
+
29
+ export function wrapUntrusted(text: string): string {
30
+ return `<untrusted>\n${text}\n</untrusted>`;
31
+ }
32
+
33
+ export interface WrapInstructionsResult {
34
+ text: string;
35
+ uuid: string;
36
+ }
37
+
38
+ export function wrapInstructions(
39
+ text: string,
40
+ uuid: string = randomUUID(),
41
+ ): WrapInstructionsResult {
42
+ return {
43
+ text: `<instructions uuid="${uuid}">\n${text}\n</instructions>`,
44
+ uuid,
45
+ };
46
+ }
47
+
48
+ /**
49
+ * Wrap an actionable error message so the LLM gets a hint without
50
+ * treating the rest of the response as instructions.
51
+ */
52
+ export function wrapToolError(toolName: string, message: string, hint?: string): string {
53
+ let out = `Tool "${toolName}" failed: ${message}`;
54
+ if (hint) out += `\nHint: ${hint}`;
55
+ return out;
56
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * MCP Resources — the second handler shape MCP clients can talk to.
3
+ *
4
+ * Tools run a verb ("call this function"). Resources expose addressable
5
+ * read-only state ("read this URI"). Hosts can subscribe to resources or
6
+ * browse them without re-invoking tools — useful for log streams, health
7
+ * snapshots, document content, etc.
8
+ *
9
+ * This kit mirrors the spec's two request shapes:
10
+ *
11
+ * - ListResourcesRequest / ListResourcesResult (concrete URIs)
12
+ * - ListResourceTemplatesRequest / ...Result (URI templates)
13
+ * - ReadResourceRequest / ReadResourceResult (resolve a URI)
14
+ *
15
+ * Consumers wire the returned `{ onList, onListTemplates, onRead }` into
16
+ * `server.setRequestHandler(...)`. Each callback handles its own error
17
+ * shape per the SDK contract.
18
+ *
19
+ * INVARIANT: like the tools dispatcher, every read goes through a perf
20
+ * span + structured error wrap. Never throw bare errors; the caller can
21
+ * inspect `error.message` for the resource URI + hint.
22
+ */
23
+
24
+ import { perf } from "@george43g/robustness";
25
+ import { wrapToolError } from "./prompt-injection.js";
26
+
27
+ /** A directly-addressable resource (URI is the full address). */
28
+ export interface ResourceListEntry {
29
+ uri: string;
30
+ name: string;
31
+ description?: string;
32
+ mimeType?: string;
33
+ }
34
+
35
+ /** A URI template (e.g. "logs://recent/{n}") clients fill in to read. */
36
+ export interface ResourceTemplate {
37
+ uriTemplate: string;
38
+ name: string;
39
+ description?: string;
40
+ mimeType?: string;
41
+ }
42
+
43
+ /** Returned by the read callback when the URI is resolved. */
44
+ export interface ResourceContent {
45
+ uri: string;
46
+ mimeType: string;
47
+ /** UTF-8 text body. Mutually exclusive with `blob`. */
48
+ text?: string;
49
+ /** base64-encoded binary body. Mutually exclusive with `text`. */
50
+ blob?: string;
51
+ }
52
+
53
+ export interface ResourcesProvider {
54
+ /** Concrete resources to expose; default: none. */
55
+ list?(): Promise<ResourceListEntry[]> | ResourceListEntry[];
56
+ /** URI templates to expose; default: none. */
57
+ listTemplates?(): Promise<ResourceTemplate[]> | ResourceTemplate[];
58
+ /**
59
+ * Resolve a URI. Throw if unknown — the wrapper turns it into a
60
+ * structured error the SDK propagates to the client.
61
+ */
62
+ read(uri: string): Promise<ResourceContent> | ResourceContent;
63
+ }
64
+
65
+ export interface BuildResourcesHandlerOptions {
66
+ provider: ResourcesProvider;
67
+ /** Optional engine label surfaced in perf spans. */
68
+ engineLabel?: string;
69
+ }
70
+
71
+ export interface ResourceHandlers {
72
+ /** Wire into `server.setRequestHandler(ListResourcesRequestSchema, ...)`. */
73
+ onList(): Promise<{ resources: ResourceListEntry[] }>;
74
+ /** Wire into `server.setRequestHandler(ListResourceTemplatesRequestSchema, ...)`. */
75
+ onListTemplates(): Promise<{ resourceTemplates: ResourceTemplate[] }>;
76
+ /** Wire into `server.setRequestHandler(ReadResourceRequestSchema, ...)`. */
77
+ onRead(request: { params: { uri: string } }): Promise<{ contents: ResourceContent[] }>;
78
+ }
79
+
80
+ export function buildResourcesHandler(opts: BuildResourcesHandlerOptions): ResourceHandlers {
81
+ const { provider, engineLabel = "ts" } = opts;
82
+
83
+ async function onList() {
84
+ const span = perf("resources.list");
85
+ try {
86
+ const resources = provider.list ? await provider.list() : [];
87
+ return { resources };
88
+ } finally {
89
+ span.end();
90
+ }
91
+ }
92
+
93
+ async function onListTemplates() {
94
+ const span = perf("resources.listTemplates");
95
+ try {
96
+ const resourceTemplates = provider.listTemplates ? await provider.listTemplates() : [];
97
+ return { resourceTemplates };
98
+ } finally {
99
+ span.end();
100
+ }
101
+ }
102
+
103
+ async function onRead(request: { params: { uri: string } }) {
104
+ const uri = request.params.uri;
105
+ const span = perf(`resources.read[${engineLabel}]`);
106
+ try {
107
+ const content = await provider.read(uri);
108
+ return { contents: [content] };
109
+ } catch (err) {
110
+ const msg = err instanceof Error ? err.message : String(err);
111
+ // wrapToolError produces a structured shape with an actionable
112
+ // hint — same pattern the tools dispatcher uses.
113
+ throw new Error(wrapToolError(`resources/read`, msg, `Unknown resource URI: "${uri}"`));
114
+ } finally {
115
+ span.end();
116
+ }
117
+ }
118
+
119
+ return { onList, onListTemplates, onRead };
120
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Sanitize untrusted user text before emitting via MCP or terminal.
3
+ *
4
+ * Lifted from imsg-mcp/src/sanitize.ts.
5
+ *
6
+ * - Strips ANSI CSI and OSC escape sequences to prevent terminal corruption.
7
+ * - Replaces NUL bytes and C0 control characters (except \n, \t, \r) with
8
+ * U+FFFD (Replacement Character) — keeps text scannable while making the
9
+ * substitution visible.
10
+ * - Truncates the string if it exceeds `maxLength` (default 4096),
11
+ * appending a horizontal ellipsis.
12
+ *
13
+ * Call this on every user-content surface in tool responses: chat
14
+ * snippets, search results, anything originally typed by a human or
15
+ * sourced from a system you don't control.
16
+ */
17
+
18
+ const ANSI_REGEX = new RegExp(
19
+ [
20
+ "[\\u001B\\u009B][[\\]()#;?]*(?:(?:(?:(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]+)*|[a-zA-Z\\d]+(?:;[-a-zA-Z\\d\\/#&.:=?%@~_]*)*)?\\u0007)",
21
+ "(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PR-TZcf-nq-uy=><~]))",
22
+ ].join("|"),
23
+ "g",
24
+ );
25
+
26
+ // Matches C0 control characters, excluding \t (0x09), \n (0x0A), and \r (0x0D)
27
+ const CONTROL_CHAR_REGEX = /[\x00-\x08\x0B\x0C\x0E-\x1F]/g;
28
+
29
+ export function sanitize(text: string | null | undefined, maxLength = 4096): string | null {
30
+ if (text == null) return null;
31
+ let sanitized = text.replace(ANSI_REGEX, "");
32
+ sanitized = sanitized.replace(CONTROL_CHAR_REGEX, "�");
33
+ if (sanitized.length > maxLength) {
34
+ sanitized = `${sanitized.slice(0, maxLength - 1)}…`;
35
+ }
36
+ return sanitized;
37
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * Tool registry — types and helpers for declaring MCP tools.
3
+ *
4
+ * Convention: each tool ships its own file with three exports:
5
+ * - the Zod input schema (for runtime validation)
6
+ * - the Zod output schema (for structuredContent shape)
7
+ * - the ToolDefinition (registration metadata)
8
+ *
9
+ * The registry collects ToolDefinitions and converts them to the SDK's
10
+ * `Tool[]` shape via `toMcpTools()`.
11
+ */
12
+
13
+ import type { Tool, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
14
+ import type { ZodTypeAny, z } from "zod";
15
+ import { zodToJsonSchema } from "zod-to-json-schema";
16
+
17
+ /**
18
+ * A single MCP content block a tool may emit.
19
+ *
20
+ * Deliberately the same two members as `@george43g/cli-kit`'s `ContentBlock`,
21
+ * and deliberately WITHOUT a catch-all `{ type: string; ... }` member. A
22
+ * catch-all overlaps `type: "text"`, so every narrowing site needs a cast — and
23
+ * a future compile error is wanted here: it lands exactly at the render site
24
+ * where a decision about the new block type has to be made.
25
+ *
26
+ * `resource` and `audio` blocks are not modelled because no caller emits them.
27
+ * Add them when one does, not from a reading of the spec.
28
+ */
29
+ export type ContentBlock =
30
+ | { type: "text"; text: string }
31
+ /** `data` is RAW base64 — no `data:` URI prefix, and not a path. */
32
+ | { type: "image"; data: string; mimeType: string };
33
+
34
+ export interface ToolDefinition<
35
+ TInput extends ZodTypeAny = ZodTypeAny,
36
+ TOutput extends ZodTypeAny = ZodTypeAny,
37
+ > {
38
+ /** Snake_case tool name, e.g. "health_check". */
39
+ name: string;
40
+ /** One-paragraph description shown to the LLM. */
41
+ description: string;
42
+ /** Zod schema for tool arguments. */
43
+ input: TInput;
44
+ /** Zod schema for tool result (structuredContent). */
45
+ output: TOutput;
46
+ /** MCP annotations: read-only / destructive / idempotent / open-world hints. */
47
+ annotations: ToolAnnotations;
48
+ /**
49
+ * Per-tool timeout in ms. Set to 0 to disable the wrapper for this tool.
50
+ * Resolved against `MCP_TOOL_TIMEOUT_DEFAULT_MS` if omitted (default 30s).
51
+ */
52
+ timeoutMs?: number;
53
+ /** When true, only register the tool if `MCP_DEV=1`. Used for `get_logs`. */
54
+ devOnly?: boolean;
55
+ /** Handler: receives parsed input + AbortSignal, returns structured output. */
56
+ handler: (input: z.infer<TInput>, signal?: AbortSignal) => Promise<z.infer<TOutput>>;
57
+ /**
58
+ * Optional: derive extra content blocks (typically an image) from the result.
59
+ * The dispatcher emits them AHEAD of the default JSON text block, so a
60
+ * screenshot tool returns `[image, text]`.
61
+ *
62
+ * Runs synchronously at dispatch time and may read a file the handler just
63
+ * wrote. It must not throw on the happy path; a throw is caught, logged as
64
+ * `to_content_failed`, and degrades to the text block alone — a tool that
65
+ * cannot render its picture still returns its answer.
66
+ *
67
+ * From browser-tab-mcp, which carried this in a vendored copy of this file.
68
+ */
69
+ toContent?: (result: z.infer<TOutput>) => ContentBlock[];
70
+ }
71
+
72
+ // AnyToolDefinition widens both Zod generic params so the registry can hold
73
+ // tools with narrow input/output schemas under a single uniform array type.
74
+ // Tool authors keep their narrow types at the declaration site; this is the
75
+ // boundary alias used internally.
76
+ export type AnyToolDefinition = ToolDefinition<any, any>;
77
+
78
+ export interface ToolRegistry {
79
+ readonly tools: readonly AnyToolDefinition[];
80
+ get(name: string): AnyToolDefinition | undefined;
81
+ /** Convert all tools (or just enabled ones) to MCP SDK shape. */
82
+ toMcpTools(includeDevOnly?: boolean): Tool[];
83
+ }
84
+
85
+ export function makeRegistry(defs: AnyToolDefinition[]): ToolRegistry {
86
+ const byName = new Map(defs.map((d) => [d.name, d]));
87
+ return {
88
+ tools: defs,
89
+ get(name) {
90
+ return byName.get(name);
91
+ },
92
+ toMcpTools(includeDevOnly = false): Tool[] {
93
+ return defs
94
+ .filter((d) => includeDevOnly || !d.devOnly)
95
+ .map(
96
+ (d) =>
97
+ // The SDK Tool type narrows inputSchema/outputSchema to a JSON-
98
+ // schema with `type: "object"` literal; zodToJsonSchema returns
99
+ // the broader JsonSchema7Type union. Cast is safe because we
100
+ // only ever build Zod object schemas as tool I/O.
101
+ ({
102
+ name: d.name,
103
+ description: d.description,
104
+ annotations: d.annotations,
105
+ inputSchema: zodToJsonSchema(d.input),
106
+ outputSchema: zodToJsonSchema(d.output),
107
+ }) as unknown as Tool,
108
+ );
109
+ },
110
+ };
111
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Streamable HTTP transport.
3
+ *
4
+ * Lifted from Gmail-MCP-Server/src/server/http.ts with the bearer token made
5
+ * caller-supplied: pass `token` directly, or let it read `tokenEnv`
6
+ * (default: MCP_HTTP_TOKEN). Single-tenant by design — one server process =
7
+ * one identity. Multi-tenant is a future enhancement; out of scope for the
8
+ * template.
9
+ *
10
+ * - POST /mcp — MCP protocol (bearer-token required)
11
+ * - GET /health — health snapshot (no auth; for reverse-proxy probes;
12
+ * returns 503 if unhealthy)
13
+ *
14
+ * TLS is delegated to a reverse proxy. Default bind is 127.0.0.1.
15
+ *
16
+ * The bearer-token check is constant-time to prevent timing attacks.
17
+ */
18
+
19
+ import { randomUUID } from "node:crypto";
20
+ import http from "node:http";
21
+ import { formatHealthText, snapshotHealth } from "@george43g/robustness";
22
+ import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
23
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
24
+
25
+ export interface HttpServerOptions {
26
+ server: McpServer;
27
+ port: number;
28
+ bind: string;
29
+ /**
30
+ * Bearer token, already resolved by the caller. Takes precedence over
31
+ * `tokenEnv`.
32
+ *
33
+ * This exists so the app can own *where* the secret comes from (env, .env,
34
+ * OS keychain, an external manager) without this transport growing an
35
+ * opinion — or a dependency — on secret storage. Omit it and the env-var
36
+ * lookup below is unchanged.
37
+ */
38
+ token?: string;
39
+ /** Env var name containing the bearer token. Default: "MCP_HTTP_TOKEN". */
40
+ tokenEnv?: string;
41
+ getCounters: () => { toolCalls: number; recentErrors: number };
42
+ log?: (line: string) => void;
43
+ }
44
+
45
+ export interface HttpServerHandle {
46
+ close(): Promise<void>;
47
+ port: number;
48
+ url: string;
49
+ }
50
+
51
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
52
+
53
+ function timingSafeEqual(a: string, b: string): boolean {
54
+ if (a.length !== b.length) return false;
55
+ let mismatch = 0;
56
+ for (let i = 0; i < a.length; i++) {
57
+ mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
58
+ }
59
+ return mismatch === 0;
60
+ }
61
+
62
+ async function readJsonBody(req: http.IncomingMessage): Promise<unknown> {
63
+ return new Promise((resolve, reject) => {
64
+ const chunks: Buffer[] = [];
65
+ let total = 0;
66
+ req.on("data", (chunk: Buffer) => {
67
+ total += chunk.length;
68
+ if (total > MAX_BODY_BYTES) {
69
+ reject(new Error(`Request body exceeds ${MAX_BODY_BYTES} bytes`));
70
+ req.destroy();
71
+ return;
72
+ }
73
+ chunks.push(chunk);
74
+ });
75
+ req.on("end", () => {
76
+ if (chunks.length === 0) {
77
+ resolve(undefined);
78
+ return;
79
+ }
80
+ const raw = Buffer.concat(chunks).toString("utf8");
81
+ try {
82
+ resolve(JSON.parse(raw));
83
+ } catch (err) {
84
+ reject(new Error(`Invalid JSON body: ${(err as Error).message}`));
85
+ }
86
+ });
87
+ req.on("error", reject);
88
+ });
89
+ }
90
+
91
+ export async function startHttpServer(opts: HttpServerOptions): Promise<HttpServerHandle> {
92
+ const log = opts.log ?? ((line: string) => process.stderr.write(`${line}\n`));
93
+ const tokenEnv = opts.tokenEnv ?? "MCP_HTTP_TOKEN";
94
+
95
+ const expectedToken = opts.token ?? process.env[tokenEnv];
96
+ if (!expectedToken || expectedToken.trim().length === 0) {
97
+ throw new Error(
98
+ `HTTP mode requires a non-empty bearer token: set ${tokenEnv}, or pass one ` +
99
+ `as \`token\`. Generate one with: openssl rand -hex 32`,
100
+ );
101
+ }
102
+
103
+ const transport = new StreamableHTTPServerTransport({
104
+ sessionIdGenerator: () => randomUUID(),
105
+ });
106
+ // The SDK's Transport interface declares optional callbacks as required
107
+ // fields under `exactOptionalPropertyTypes`. Connect via a structural cast.
108
+ await opts.server.connect(transport as unknown as Parameters<McpServer["connect"]>[0]);
109
+
110
+ const httpServer = http.createServer(async (req, res) => {
111
+ try {
112
+ const url = req.url ?? "/";
113
+ const method = req.method ?? "GET";
114
+
115
+ if (method === "GET" && (url === "/health" || url.startsWith("/health?"))) {
116
+ const snap = snapshotHealth(opts.getCounters());
117
+ res.writeHead(snap.status === "unhealthy" ? 503 : 200, {
118
+ "content-type": "text/plain; charset=utf-8",
119
+ });
120
+ res.end(formatHealthText(snap));
121
+ return;
122
+ }
123
+
124
+ if (!url.startsWith("/mcp")) {
125
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
126
+ res.end("Not found. Use POST /mcp for MCP requests, GET /health for status.");
127
+ return;
128
+ }
129
+
130
+ const authHeader = (req.headers.authorization ?? "").trim();
131
+ const provided = authHeader.startsWith("Bearer ")
132
+ ? authHeader.slice("Bearer ".length).trim()
133
+ : "";
134
+ if (!provided || !timingSafeEqual(provided, expectedToken)) {
135
+ res.writeHead(401, {
136
+ "content-type": "text/plain; charset=utf-8",
137
+ "www-authenticate": 'Bearer realm="mcp"',
138
+ });
139
+ res.end("Unauthorized. Provide an Authorization: Bearer <token> header.");
140
+ return;
141
+ }
142
+
143
+ let body: unknown;
144
+ if (method === "POST") {
145
+ body = await readJsonBody(req);
146
+ }
147
+
148
+ await transport.handleRequest(req, res, body);
149
+ } catch (err) {
150
+ const e = err as Error;
151
+ log(`http_error: ${e.message}`);
152
+ if (!res.headersSent) {
153
+ res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
154
+ res.end(`Internal error: ${e.message}`);
155
+ } else {
156
+ try {
157
+ res.end();
158
+ } catch {
159
+ // swallow
160
+ }
161
+ }
162
+ }
163
+ });
164
+
165
+ await new Promise<void>((resolveListen, rejectListen) => {
166
+ httpServer.once("error", rejectListen);
167
+ httpServer.listen(opts.port, opts.bind, () => {
168
+ httpServer.off("error", rejectListen);
169
+ resolveListen();
170
+ });
171
+ });
172
+
173
+ const addr = httpServer.address();
174
+ const actualPort = typeof addr === "object" && addr ? addr.port : opts.port;
175
+ const url = `http://${opts.bind}:${actualPort}`;
176
+ log(`MCP HTTP listening on ${url}`);
177
+ log(` POST ${url}/mcp — MCP Streamable HTTP (Authorization: Bearer required)`);
178
+ log(` GET ${url}/health — health snapshot (no auth)`);
179
+
180
+ return {
181
+ port: actualPort,
182
+ url,
183
+ async close() {
184
+ await new Promise<void>((resolveClose) => httpServer.close(() => resolveClose()));
185
+ await transport.close();
186
+ },
187
+ };
188
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * stdio transport bootstrap.
3
+ *
4
+ * Wires an MCP `Server` instance to `StdioServerTransport` with the
5
+ * standard set of lifecycle handlers (shutdown, stdin EOF, orphan watch,
6
+ * watchdog, heap monitor).
7
+ *
8
+ * Critical invariant after this returns: NEVER write to stdout. JSON-RPC
9
+ * owns it. All logging must go through @george43g/robustness/logger.
10
+ */
11
+
12
+ import {
13
+ enableOrphanWatchdog,
14
+ enableStdinEofDetection,
15
+ getShutdownCause,
16
+ installShutdownHandlers,
17
+ installWatchdog,
18
+ logShutdown,
19
+ logStartup,
20
+ registerCleanup,
21
+ startHeapMonitor,
22
+ } from "@george43g/robustness";
23
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
24
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
25
+
26
+ export interface StartStdioOptions {
27
+ server: Server;
28
+ entrypoint: string;
29
+ }
30
+
31
+ export async function startStdio({ server, entrypoint }: StartStdioOptions): Promise<void> {
32
+ installShutdownHandlers();
33
+ enableStdinEofDetection();
34
+ enableOrphanWatchdog();
35
+ installWatchdog();
36
+ startHeapMonitor();
37
+ logStartup(entrypoint);
38
+
39
+ // Without this, `logStartup` has no counterpart: every clean exit looks like
40
+ // a crash to the rule AGENTS.md states ("file without `shutdown` = crash"),
41
+ // and stdin EOF — the MCP host going away, the most common exit of all —
42
+ // leaves no trace at all.
43
+ //
44
+ // Write-once, two ways, because either alone is insufficient.
45
+ //
46
+ // Registered LAST: the controller's `exit` listener sweeps the whole cleanup
47
+ // registry synchronously, so a cleanup the async pass already ran executes a
48
+ // SECOND time when a later one hangs and trips the force-exit net. Last is
49
+ // the position where the sweep is the only invocation.
50
+ //
51
+ // Guarded as well, because "last" is not a position you can hold. Anything
52
+ // registering a cleanup at RUNTIME lands after this one — a lazily armed
53
+ // watcher in a tool handler, an ink component registering on mount (tui-kit's
54
+ // FullScreenInk does exactly that). Measured on robustness 0.8.1: marker
55
+ // registered last, then a runtime registration that hangs → 2 lines unguarded,
56
+ // 1 guarded.
57
+ //
58
+ // NOT done inside the shutdown controller: EQStack and up-bank-mcp already
59
+ // call `logShutdown` themselves and would get a duplicate line. The transport
60
+ // is also the component that owns stdout, so the decision belongs here.
61
+ let markerWritten = false;
62
+ registerCleanup(() => {
63
+ if (markerWritten) return;
64
+ markerWritten = true;
65
+ logShutdown(getShutdownCause());
66
+ });
67
+
68
+ const transport = new StdioServerTransport();
69
+ await server.connect(transport);
70
+ }