@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.
- package/LICENSE +21 -0
- package/README.md +105 -0
- package/dist/dispatch.d.ts +58 -0
- package/dist/dispatch.d.ts.map +1 -0
- package/dist/dispatch.js +110 -0
- package/dist/dispatch.js.map +1 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -0
- package/dist/prompt-injection.d.ts +37 -0
- package/dist/prompt-injection.d.ts.map +1 -0
- package/dist/prompt-injection.js +46 -0
- package/dist/prompt-injection.js.map +1 -0
- package/dist/resources.d.ts +81 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +66 -0
- package/dist/resources.js.map +1 -0
- package/dist/sanitize.d.ts +18 -0
- package/dist/sanitize.d.ts.map +1 -0
- package/dist/sanitize.js +33 -0
- package/dist/sanitize.js.map +1 -0
- package/dist/tool-registry.d.ts +78 -0
- package/dist/tool-registry.d.ts.map +1 -0
- package/dist/tool-registry.js +38 -0
- package/dist/tool-registry.js.map +1 -0
- package/dist/transports/http.d.ts +47 -0
- package/dist/transports/http.d.ts.map +1 -0
- package/dist/transports/http.js +150 -0
- package/dist/transports/http.js.map +1 -0
- package/dist/transports/stdio.d.ts +17 -0
- package/dist/transports/stdio.d.ts.map +1 -0
- package/dist/transports/stdio.js +53 -0
- package/dist/transports/stdio.js.map +1 -0
- package/package.json +72 -0
- package/src/dispatch.ts +172 -0
- package/src/index.ts +34 -0
- package/src/prompt-injection.ts +56 -0
- package/src/resources.ts +120 -0
- package/src/sanitize.ts +37 -0
- package/src/tool-registry.ts +111 -0
- package/src/transports/http.ts +188 -0
- package/src/transports/stdio.ts +70 -0
|
@@ -0,0 +1,78 @@
|
|
|
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
|
+
import type { Tool, ToolAnnotations } from "@modelcontextprotocol/sdk/types.js";
|
|
13
|
+
import type { ZodTypeAny, z } from "zod";
|
|
14
|
+
/**
|
|
15
|
+
* A single MCP content block a tool may emit.
|
|
16
|
+
*
|
|
17
|
+
* Deliberately the same two members as `@george43g/cli-kit`'s `ContentBlock`,
|
|
18
|
+
* and deliberately WITHOUT a catch-all `{ type: string; ... }` member. A
|
|
19
|
+
* catch-all overlaps `type: "text"`, so every narrowing site needs a cast — and
|
|
20
|
+
* a future compile error is wanted here: it lands exactly at the render site
|
|
21
|
+
* where a decision about the new block type has to be made.
|
|
22
|
+
*
|
|
23
|
+
* `resource` and `audio` blocks are not modelled because no caller emits them.
|
|
24
|
+
* Add them when one does, not from a reading of the spec.
|
|
25
|
+
*/
|
|
26
|
+
export type ContentBlock = {
|
|
27
|
+
type: "text";
|
|
28
|
+
text: string;
|
|
29
|
+
}
|
|
30
|
+
/** `data` is RAW base64 — no `data:` URI prefix, and not a path. */
|
|
31
|
+
| {
|
|
32
|
+
type: "image";
|
|
33
|
+
data: string;
|
|
34
|
+
mimeType: string;
|
|
35
|
+
};
|
|
36
|
+
export interface ToolDefinition<TInput extends ZodTypeAny = ZodTypeAny, TOutput extends ZodTypeAny = ZodTypeAny> {
|
|
37
|
+
/** Snake_case tool name, e.g. "health_check". */
|
|
38
|
+
name: string;
|
|
39
|
+
/** One-paragraph description shown to the LLM. */
|
|
40
|
+
description: string;
|
|
41
|
+
/** Zod schema for tool arguments. */
|
|
42
|
+
input: TInput;
|
|
43
|
+
/** Zod schema for tool result (structuredContent). */
|
|
44
|
+
output: TOutput;
|
|
45
|
+
/** MCP annotations: read-only / destructive / idempotent / open-world hints. */
|
|
46
|
+
annotations: ToolAnnotations;
|
|
47
|
+
/**
|
|
48
|
+
* Per-tool timeout in ms. Set to 0 to disable the wrapper for this tool.
|
|
49
|
+
* Resolved against `MCP_TOOL_TIMEOUT_DEFAULT_MS` if omitted (default 30s).
|
|
50
|
+
*/
|
|
51
|
+
timeoutMs?: number;
|
|
52
|
+
/** When true, only register the tool if `MCP_DEV=1`. Used for `get_logs`. */
|
|
53
|
+
devOnly?: boolean;
|
|
54
|
+
/** Handler: receives parsed input + AbortSignal, returns structured output. */
|
|
55
|
+
handler: (input: z.infer<TInput>, signal?: AbortSignal) => Promise<z.infer<TOutput>>;
|
|
56
|
+
/**
|
|
57
|
+
* Optional: derive extra content blocks (typically an image) from the result.
|
|
58
|
+
* The dispatcher emits them AHEAD of the default JSON text block, so a
|
|
59
|
+
* screenshot tool returns `[image, text]`.
|
|
60
|
+
*
|
|
61
|
+
* Runs synchronously at dispatch time and may read a file the handler just
|
|
62
|
+
* wrote. It must not throw on the happy path; a throw is caught, logged as
|
|
63
|
+
* `to_content_failed`, and degrades to the text block alone — a tool that
|
|
64
|
+
* cannot render its picture still returns its answer.
|
|
65
|
+
*
|
|
66
|
+
* From browser-tab-mcp, which carried this in a vendored copy of this file.
|
|
67
|
+
*/
|
|
68
|
+
toContent?: (result: z.infer<TOutput>) => ContentBlock[];
|
|
69
|
+
}
|
|
70
|
+
export type AnyToolDefinition = ToolDefinition<any, any>;
|
|
71
|
+
export interface ToolRegistry {
|
|
72
|
+
readonly tools: readonly AnyToolDefinition[];
|
|
73
|
+
get(name: string): AnyToolDefinition | undefined;
|
|
74
|
+
/** Convert all tools (or just enabled ones) to MCP SDK shape. */
|
|
75
|
+
toMcpTools(includeDevOnly?: boolean): Tool[];
|
|
76
|
+
}
|
|
77
|
+
export declare function makeRegistry(defs: AnyToolDefinition[]): ToolRegistry;
|
|
78
|
+
//# sourceMappingURL=tool-registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool-registry.d.ts","sourceRoot":"","sources":["../src/tool-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAEH,OAAO,KAAK,EAAE,IAAI,EAAE,eAAe,EAAE,MAAM,oCAAoC,CAAC;AAChF,OAAO,KAAK,EAAE,UAAU,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAGzC;;;;;;;;;;;GAWG;AACH,MAAM,MAAM,YAAY,GACpB;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE;AAChC,oEAAoE;GAClE;IAAE,IAAI,EAAE,OAAO,CAAC;IAAC,IAAI,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAA;CAAE,CAAC;AAEtD,MAAM,WAAW,cAAc,CAC7B,MAAM,SAAS,UAAU,GAAG,UAAU,EACtC,OAAO,SAAS,UAAU,GAAG,UAAU;IAEvC,iDAAiD;IACjD,IAAI,EAAE,MAAM,CAAC;IACb,kDAAkD;IAClD,WAAW,EAAE,MAAM,CAAC;IACpB,qCAAqC;IACrC,KAAK,EAAE,MAAM,CAAC;IACd,sDAAsD;IACtD,MAAM,EAAE,OAAO,CAAC;IAChB,gFAAgF;IAChF,WAAW,EAAE,eAAe,CAAC;IAC7B;;;OAGG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,6EAA6E;IAC7E,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,+EAA+E;IAC/E,OAAO,EAAE,CAAC,KAAK,EAAE,CAAC,CAAC,KAAK,CAAC,MAAM,CAAC,EAAE,MAAM,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC;IACrF;;;;;;;;;;;OAWG;IACH,SAAS,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,KAAK,YAAY,EAAE,CAAC;CAC1D;AAMD,MAAM,MAAM,iBAAiB,GAAG,cAAc,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;AAEzD,MAAM,WAAW,YAAY;IAC3B,QAAQ,CAAC,KAAK,EAAE,SAAS,iBAAiB,EAAE,CAAC;IAC7C,GAAG,CAAC,IAAI,EAAE,MAAM,GAAG,iBAAiB,GAAG,SAAS,CAAC;IACjD,iEAAiE;IACjE,UAAU,CAAC,cAAc,CAAC,EAAE,OAAO,GAAG,IAAI,EAAE,CAAC;CAC9C;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,iBAAiB,EAAE,GAAG,YAAY,CA0BpE"}
|
|
@@ -0,0 +1,38 @@
|
|
|
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
|
+
import { zodToJsonSchema } from "zod-to-json-schema";
|
|
13
|
+
export function makeRegistry(defs) {
|
|
14
|
+
const byName = new Map(defs.map((d) => [d.name, d]));
|
|
15
|
+
return {
|
|
16
|
+
tools: defs,
|
|
17
|
+
get(name) {
|
|
18
|
+
return byName.get(name);
|
|
19
|
+
},
|
|
20
|
+
toMcpTools(includeDevOnly = false) {
|
|
21
|
+
return defs
|
|
22
|
+
.filter((d) => includeDevOnly || !d.devOnly)
|
|
23
|
+
.map((d) =>
|
|
24
|
+
// The SDK Tool type narrows inputSchema/outputSchema to a JSON-
|
|
25
|
+
// schema with `type: "object"` literal; zodToJsonSchema returns
|
|
26
|
+
// the broader JsonSchema7Type union. Cast is safe because we
|
|
27
|
+
// only ever build Zod object schemas as tool I/O.
|
|
28
|
+
({
|
|
29
|
+
name: d.name,
|
|
30
|
+
description: d.description,
|
|
31
|
+
annotations: d.annotations,
|
|
32
|
+
inputSchema: zodToJsonSchema(d.input),
|
|
33
|
+
outputSchema: zodToJsonSchema(d.output),
|
|
34
|
+
}));
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=tool-registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"tool-registry.js","sourceRoot":"","sources":["../src/tool-registry.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;GAUG;AAIH,OAAO,EAAE,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAsErD,MAAM,UAAU,YAAY,CAAC,IAAyB;IACpD,MAAM,MAAM,GAAG,IAAI,GAAG,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,CAAC,IAAI,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC;IACrD,OAAO;QACL,KAAK,EAAE,IAAI;QACX,GAAG,CAAC,IAAI;YACN,OAAO,MAAM,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC;QAC1B,CAAC;QACD,UAAU,CAAC,cAAc,GAAG,KAAK;YAC/B,OAAO,IAAI;iBACR,MAAM,CAAC,CAAC,CAAC,EAAE,EAAE,CAAC,cAAc,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC;iBAC3C,GAAG,CACF,CAAC,CAAC,EAAE,EAAE;YACJ,gEAAgE;YAChE,gEAAgE;YAChE,6DAA6D;YAC7D,kDAAkD;YAClD,CAAC;gBACC,IAAI,EAAE,CAAC,CAAC,IAAI;gBACZ,WAAW,EAAE,CAAC,CAAC,WAAW;gBAC1B,WAAW,EAAE,CAAC,CAAC,WAAW;gBAC1B,WAAW,EAAE,eAAe,CAAC,CAAC,CAAC,KAAK,CAAC;gBACrC,YAAY,EAAE,eAAe,CAAC,CAAC,CAAC,MAAM,CAAC;aACxC,CAAoB,CACxB,CAAC;QACN,CAAC;KACF,CAAC;AACJ,CAAC"}
|
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
19
|
+
export interface HttpServerOptions {
|
|
20
|
+
server: McpServer;
|
|
21
|
+
port: number;
|
|
22
|
+
bind: string;
|
|
23
|
+
/**
|
|
24
|
+
* Bearer token, already resolved by the caller. Takes precedence over
|
|
25
|
+
* `tokenEnv`.
|
|
26
|
+
*
|
|
27
|
+
* This exists so the app can own *where* the secret comes from (env, .env,
|
|
28
|
+
* OS keychain, an external manager) without this transport growing an
|
|
29
|
+
* opinion — or a dependency — on secret storage. Omit it and the env-var
|
|
30
|
+
* lookup below is unchanged.
|
|
31
|
+
*/
|
|
32
|
+
token?: string;
|
|
33
|
+
/** Env var name containing the bearer token. Default: "MCP_HTTP_TOKEN". */
|
|
34
|
+
tokenEnv?: string;
|
|
35
|
+
getCounters: () => {
|
|
36
|
+
toolCalls: number;
|
|
37
|
+
recentErrors: number;
|
|
38
|
+
};
|
|
39
|
+
log?: (line: string) => void;
|
|
40
|
+
}
|
|
41
|
+
export interface HttpServerHandle {
|
|
42
|
+
close(): Promise<void>;
|
|
43
|
+
port: number;
|
|
44
|
+
url: string;
|
|
45
|
+
}
|
|
46
|
+
export declare function startHttpServer(opts: HttpServerOptions): Promise<HttpServerHandle>;
|
|
47
|
+
//# sourceMappingURL=http.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.d.ts","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;GAgBG;AAKH,OAAO,EAAE,MAAM,IAAI,SAAS,EAAE,MAAM,2CAA2C,CAAC;AAGhF,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,SAAS,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb;;;;;;;;OAQG;IACH,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,2EAA2E;IAC3E,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,WAAW,EAAE,MAAM;QAAE,SAAS,EAAE,MAAM,CAAC;QAAC,YAAY,EAAE,MAAM,CAAA;KAAE,CAAC;IAC/D,GAAG,CAAC,EAAE,CAAC,IAAI,EAAE,MAAM,KAAK,IAAI,CAAC;CAC9B;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;IACvB,IAAI,EAAE,MAAM,CAAC;IACb,GAAG,EAAE,MAAM,CAAC;CACb;AA0CD,wBAAsB,eAAe,CAAC,IAAI,EAAE,iBAAiB,GAAG,OAAO,CAAC,gBAAgB,CAAC,CAiGxF"}
|
|
@@ -0,0 +1,150 @@
|
|
|
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
|
+
import { randomUUID } from "node:crypto";
|
|
19
|
+
import http from "node:http";
|
|
20
|
+
import { formatHealthText, snapshotHealth } from "@george43g/robustness";
|
|
21
|
+
import { Server as McpServer } from "@modelcontextprotocol/sdk/server/index.js";
|
|
22
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
23
|
+
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
24
|
+
function timingSafeEqual(a, b) {
|
|
25
|
+
if (a.length !== b.length)
|
|
26
|
+
return false;
|
|
27
|
+
let mismatch = 0;
|
|
28
|
+
for (let i = 0; i < a.length; i++) {
|
|
29
|
+
mismatch |= a.charCodeAt(i) ^ b.charCodeAt(i);
|
|
30
|
+
}
|
|
31
|
+
return mismatch === 0;
|
|
32
|
+
}
|
|
33
|
+
async function readJsonBody(req) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const chunks = [];
|
|
36
|
+
let total = 0;
|
|
37
|
+
req.on("data", (chunk) => {
|
|
38
|
+
total += chunk.length;
|
|
39
|
+
if (total > MAX_BODY_BYTES) {
|
|
40
|
+
reject(new Error(`Request body exceeds ${MAX_BODY_BYTES} bytes`));
|
|
41
|
+
req.destroy();
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
chunks.push(chunk);
|
|
45
|
+
});
|
|
46
|
+
req.on("end", () => {
|
|
47
|
+
if (chunks.length === 0) {
|
|
48
|
+
resolve(undefined);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
52
|
+
try {
|
|
53
|
+
resolve(JSON.parse(raw));
|
|
54
|
+
}
|
|
55
|
+
catch (err) {
|
|
56
|
+
reject(new Error(`Invalid JSON body: ${err.message}`));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
req.on("error", reject);
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
export async function startHttpServer(opts) {
|
|
63
|
+
const log = opts.log ?? ((line) => process.stderr.write(`${line}\n`));
|
|
64
|
+
const tokenEnv = opts.tokenEnv ?? "MCP_HTTP_TOKEN";
|
|
65
|
+
const expectedToken = opts.token ?? process.env[tokenEnv];
|
|
66
|
+
if (!expectedToken || expectedToken.trim().length === 0) {
|
|
67
|
+
throw new Error(`HTTP mode requires a non-empty bearer token: set ${tokenEnv}, or pass one ` +
|
|
68
|
+
`as \`token\`. Generate one with: openssl rand -hex 32`);
|
|
69
|
+
}
|
|
70
|
+
const transport = new StreamableHTTPServerTransport({
|
|
71
|
+
sessionIdGenerator: () => randomUUID(),
|
|
72
|
+
});
|
|
73
|
+
// The SDK's Transport interface declares optional callbacks as required
|
|
74
|
+
// fields under `exactOptionalPropertyTypes`. Connect via a structural cast.
|
|
75
|
+
await opts.server.connect(transport);
|
|
76
|
+
const httpServer = http.createServer(async (req, res) => {
|
|
77
|
+
try {
|
|
78
|
+
const url = req.url ?? "/";
|
|
79
|
+
const method = req.method ?? "GET";
|
|
80
|
+
if (method === "GET" && (url === "/health" || url.startsWith("/health?"))) {
|
|
81
|
+
const snap = snapshotHealth(opts.getCounters());
|
|
82
|
+
res.writeHead(snap.status === "unhealthy" ? 503 : 200, {
|
|
83
|
+
"content-type": "text/plain; charset=utf-8",
|
|
84
|
+
});
|
|
85
|
+
res.end(formatHealthText(snap));
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (!url.startsWith("/mcp")) {
|
|
89
|
+
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
90
|
+
res.end("Not found. Use POST /mcp for MCP requests, GET /health for status.");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const authHeader = (req.headers.authorization ?? "").trim();
|
|
94
|
+
const provided = authHeader.startsWith("Bearer ")
|
|
95
|
+
? authHeader.slice("Bearer ".length).trim()
|
|
96
|
+
: "";
|
|
97
|
+
if (!provided || !timingSafeEqual(provided, expectedToken)) {
|
|
98
|
+
res.writeHead(401, {
|
|
99
|
+
"content-type": "text/plain; charset=utf-8",
|
|
100
|
+
"www-authenticate": 'Bearer realm="mcp"',
|
|
101
|
+
});
|
|
102
|
+
res.end("Unauthorized. Provide an Authorization: Bearer <token> header.");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
let body;
|
|
106
|
+
if (method === "POST") {
|
|
107
|
+
body = await readJsonBody(req);
|
|
108
|
+
}
|
|
109
|
+
await transport.handleRequest(req, res, body);
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
const e = err;
|
|
113
|
+
log(`http_error: ${e.message}`);
|
|
114
|
+
if (!res.headersSent) {
|
|
115
|
+
res.writeHead(500, { "content-type": "text/plain; charset=utf-8" });
|
|
116
|
+
res.end(`Internal error: ${e.message}`);
|
|
117
|
+
}
|
|
118
|
+
else {
|
|
119
|
+
try {
|
|
120
|
+
res.end();
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
// swallow
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
await new Promise((resolveListen, rejectListen) => {
|
|
129
|
+
httpServer.once("error", rejectListen);
|
|
130
|
+
httpServer.listen(opts.port, opts.bind, () => {
|
|
131
|
+
httpServer.off("error", rejectListen);
|
|
132
|
+
resolveListen();
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
const addr = httpServer.address();
|
|
136
|
+
const actualPort = typeof addr === "object" && addr ? addr.port : opts.port;
|
|
137
|
+
const url = `http://${opts.bind}:${actualPort}`;
|
|
138
|
+
log(`MCP HTTP listening on ${url}`);
|
|
139
|
+
log(` POST ${url}/mcp — MCP Streamable HTTP (Authorization: Bearer required)`);
|
|
140
|
+
log(` GET ${url}/health — health snapshot (no auth)`);
|
|
141
|
+
return {
|
|
142
|
+
port: actualPort,
|
|
143
|
+
url,
|
|
144
|
+
async close() {
|
|
145
|
+
await new Promise((resolveClose) => httpServer.close(() => resolveClose()));
|
|
146
|
+
await transport.close();
|
|
147
|
+
},
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
//# sourceMappingURL=http.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"http.js","sourceRoot":"","sources":["../../src/transports/http.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
12
|
+
export interface StartStdioOptions {
|
|
13
|
+
server: Server;
|
|
14
|
+
entrypoint: string;
|
|
15
|
+
}
|
|
16
|
+
export declare function startStdio({ server, entrypoint }: StartStdioOptions): Promise<void>;
|
|
17
|
+
//# sourceMappingURL=stdio.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stdio.d.ts","sourceRoot":"","sources":["../../src/transports/stdio.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAaH,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AAGnE,MAAM,WAAW,iBAAiB;IAChC,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,wBAAsB,UAAU,CAAC,EAAE,MAAM,EAAE,UAAU,EAAE,EAAE,iBAAiB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuCzF"}
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
import { enableOrphanWatchdog, enableStdinEofDetection, getShutdownCause, installShutdownHandlers, installWatchdog, logShutdown, logStartup, registerCleanup, startHeapMonitor, } from "@george43g/robustness";
|
|
12
|
+
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
13
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
14
|
+
export async function startStdio({ server, entrypoint }) {
|
|
15
|
+
installShutdownHandlers();
|
|
16
|
+
enableStdinEofDetection();
|
|
17
|
+
enableOrphanWatchdog();
|
|
18
|
+
installWatchdog();
|
|
19
|
+
startHeapMonitor();
|
|
20
|
+
logStartup(entrypoint);
|
|
21
|
+
// Without this, `logStartup` has no counterpart: every clean exit looks like
|
|
22
|
+
// a crash to the rule AGENTS.md states ("file without `shutdown` = crash"),
|
|
23
|
+
// and stdin EOF — the MCP host going away, the most common exit of all —
|
|
24
|
+
// leaves no trace at all.
|
|
25
|
+
//
|
|
26
|
+
// Write-once, two ways, because either alone is insufficient.
|
|
27
|
+
//
|
|
28
|
+
// Registered LAST: the controller's `exit` listener sweeps the whole cleanup
|
|
29
|
+
// registry synchronously, so a cleanup the async pass already ran executes a
|
|
30
|
+
// SECOND time when a later one hangs and trips the force-exit net. Last is
|
|
31
|
+
// the position where the sweep is the only invocation.
|
|
32
|
+
//
|
|
33
|
+
// Guarded as well, because "last" is not a position you can hold. Anything
|
|
34
|
+
// registering a cleanup at RUNTIME lands after this one — a lazily armed
|
|
35
|
+
// watcher in a tool handler, an ink component registering on mount (tui-kit's
|
|
36
|
+
// FullScreenInk does exactly that). Measured on robustness 0.8.1: marker
|
|
37
|
+
// registered last, then a runtime registration that hangs → 2 lines unguarded,
|
|
38
|
+
// 1 guarded.
|
|
39
|
+
//
|
|
40
|
+
// NOT done inside the shutdown controller: EQStack and up-bank-mcp already
|
|
41
|
+
// call `logShutdown` themselves and would get a duplicate line. The transport
|
|
42
|
+
// is also the component that owns stdout, so the decision belongs here.
|
|
43
|
+
let markerWritten = false;
|
|
44
|
+
registerCleanup(() => {
|
|
45
|
+
if (markerWritten)
|
|
46
|
+
return;
|
|
47
|
+
markerWritten = true;
|
|
48
|
+
logShutdown(getShutdownCause());
|
|
49
|
+
});
|
|
50
|
+
const transport = new StdioServerTransport();
|
|
51
|
+
await server.connect(transport);
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=stdio.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"stdio.js","sourceRoot":"","sources":["../../src/transports/stdio.ts"],"names":[],"mappings":"AAAA;;;;;;;;;GASG;AAEH,OAAO,EACL,oBAAoB,EACpB,uBAAuB,EACvB,gBAAgB,EAChB,uBAAuB,EACvB,eAAe,EACf,WAAW,EACX,UAAU,EACV,eAAe,EACf,gBAAgB,GACjB,MAAM,uBAAuB,CAAC;AAC/B,OAAO,EAAE,MAAM,EAAE,MAAM,2CAA2C,CAAC;AACnE,OAAO,EAAE,oBAAoB,EAAE,MAAM,2CAA2C,CAAC;AAOjF,MAAM,CAAC,KAAK,UAAU,UAAU,CAAC,EAAE,MAAM,EAAE,UAAU,EAAqB;IACxE,uBAAuB,EAAE,CAAC;IAC1B,uBAAuB,EAAE,CAAC;IAC1B,oBAAoB,EAAE,CAAC;IACvB,eAAe,EAAE,CAAC;IAClB,gBAAgB,EAAE,CAAC;IACnB,UAAU,CAAC,UAAU,CAAC,CAAC;IAEvB,6EAA6E;IAC7E,4EAA4E;IAC5E,yEAAyE;IACzE,0BAA0B;IAC1B,EAAE;IACF,8DAA8D;IAC9D,EAAE;IACF,6EAA6E;IAC7E,6EAA6E;IAC7E,2EAA2E;IAC3E,uDAAuD;IACvD,EAAE;IACF,2EAA2E;IAC3E,yEAAyE;IACzE,8EAA8E;IAC9E,yEAAyE;IACzE,+EAA+E;IAC/E,aAAa;IACb,EAAE;IACF,2EAA2E;IAC3E,8EAA8E;IAC9E,wEAAwE;IACxE,IAAI,aAAa,GAAG,KAAK,CAAC;IAC1B,eAAe,CAAC,GAAG,EAAE;QACnB,IAAI,aAAa;YAAE,OAAO;QAC1B,aAAa,GAAG,IAAI,CAAC;QACrB,WAAW,CAAC,gBAAgB,EAAE,CAAC,CAAC;IAClC,CAAC,CAAC,CAAC;IAEH,MAAM,SAAS,GAAG,IAAI,oBAAoB,EAAE,CAAC;IAC7C,MAAM,MAAM,CAAC,OAAO,CAAC,SAAS,CAAC,CAAC;AAClC,CAAC"}
|
package/package.json
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@george43g/mcp-kit",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "MCP server building blocks: tool registry types, dispatcher with timeout/perf/abort/error-wrap baked in, stdio + Streamable HTTP transports, sanitize() for user content, UUID-gated prompt-injection helpers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"engines": {
|
|
8
|
+
"node": ">=24"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/george43g/mcp-cli-starter-template.git",
|
|
13
|
+
"directory": "packages/mcp-kit"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"main": "./dist/index.js",
|
|
19
|
+
"types": "./dist/index.d.ts",
|
|
20
|
+
"exports": {
|
|
21
|
+
".": {
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"import": "./dist/index.js",
|
|
24
|
+
"default": "./dist/index.js"
|
|
25
|
+
},
|
|
26
|
+
"./http": {
|
|
27
|
+
"types": "./dist/transports/http.d.ts",
|
|
28
|
+
"import": "./dist/transports/http.js",
|
|
29
|
+
"default": "./dist/transports/http.js"
|
|
30
|
+
},
|
|
31
|
+
"./stdio": {
|
|
32
|
+
"types": "./dist/transports/stdio.d.ts",
|
|
33
|
+
"import": "./dist/transports/stdio.js",
|
|
34
|
+
"default": "./dist/transports/stdio.js"
|
|
35
|
+
},
|
|
36
|
+
"./package.json": "./package.json"
|
|
37
|
+
},
|
|
38
|
+
"files": [
|
|
39
|
+
"dist",
|
|
40
|
+
"src",
|
|
41
|
+
"!src/**/*.test.ts",
|
|
42
|
+
"README.md",
|
|
43
|
+
"LICENSE"
|
|
44
|
+
],
|
|
45
|
+
"scripts": {
|
|
46
|
+
"build": "tsc -p tsconfig.json",
|
|
47
|
+
"pack:check": "pnpm build && npm pack --dry-run --json",
|
|
48
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
49
|
+
"test": "vitest run",
|
|
50
|
+
"clean": "rm -rf dist coverage",
|
|
51
|
+
"test:coverage": "vitest run --coverage"
|
|
52
|
+
},
|
|
53
|
+
"dependencies": {
|
|
54
|
+
"@george43g/robustness": ">=0.11.0 <1",
|
|
55
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
56
|
+
"zod": "^3.23.0",
|
|
57
|
+
"zod-to-json-schema": "^3.25.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@george43g/tsconfig": "workspace:*",
|
|
61
|
+
"@george43g/vitest-config": "workspace:*",
|
|
62
|
+
"@semantic-release/changelog": "^6.0.3",
|
|
63
|
+
"@semantic-release/git": "^10.0.1",
|
|
64
|
+
"@semantic-release/npm": "^13.1.5",
|
|
65
|
+
"@types/node": "^24.13.3",
|
|
66
|
+
"@vitest/coverage-v8": "^3.2.7",
|
|
67
|
+
"semantic-release": "^24.2.0",
|
|
68
|
+
"semantic-release-monorepo": "^8.0.2",
|
|
69
|
+
"typescript": "^5.7.0",
|
|
70
|
+
"vitest": "^3.2.7"
|
|
71
|
+
}
|
|
72
|
+
}
|
package/src/dispatch.ts
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dispatcher — the single place every MCP tool call passes through.
|
|
3
|
+
*
|
|
4
|
+
* DISPATCHER INVARIANTS (do not weaken without consulting AGENTS.md):
|
|
5
|
+
* 1. Every tool runs through withTimeout — declare a per-tool timeout
|
|
6
|
+
* in ToolDefinition.timeoutMs or rely on MCP_TOOL_TIMEOUT_DEFAULT_MS.
|
|
7
|
+
* 2. noteActivity() fires on every dispatch (feeds the idle watchdog).
|
|
8
|
+
* 3. perf() span around every handler.
|
|
9
|
+
* 4. Errors wrapped with actionable hint + tool name (never bare error.message).
|
|
10
|
+
* 5. AbortSignal honored — long-running handlers check signal?.aborted
|
|
11
|
+
* between iterations and return early with a structured error.
|
|
12
|
+
* 6. NEVER console.log after StdioServerTransport.connect() — JSON-RPC owns
|
|
13
|
+
* stdout. Log via @george43g/robustness/logger.
|
|
14
|
+
* 7. Tool responses return { content, structuredContent } per the MCP spec,
|
|
15
|
+
* plus a perf footer (engine + dur_ms) in `_meta`.
|
|
16
|
+
*
|
|
17
|
+
* The exported `buildDispatcher` returns a function whose shape matches the
|
|
18
|
+
* SDK's `CallToolRequest` handler.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
envNum,
|
|
23
|
+
error as logError,
|
|
24
|
+
warn as logWarn,
|
|
25
|
+
noteActivity,
|
|
26
|
+
perf,
|
|
27
|
+
ToolTimeoutError,
|
|
28
|
+
withTimeout,
|
|
29
|
+
} from "@george43g/robustness";
|
|
30
|
+
import type { ZodError } from "zod";
|
|
31
|
+
import { wrapToolError } from "./prompt-injection.js";
|
|
32
|
+
import type { ContentBlock, ToolRegistry } from "./tool-registry.js";
|
|
33
|
+
|
|
34
|
+
export interface ToolResult {
|
|
35
|
+
content: ContentBlock[];
|
|
36
|
+
structuredContent?: unknown;
|
|
37
|
+
isError?: boolean;
|
|
38
|
+
_meta?: Record<string, unknown>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DispatcherCounters {
|
|
42
|
+
/** Total successful + failed tool calls. */
|
|
43
|
+
toolCalls: number;
|
|
44
|
+
/** Errors in the last 5 minutes (caller maintains via dispatcher hook). */
|
|
45
|
+
recentErrors: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface BuildDispatcherOptions {
|
|
49
|
+
registry: ToolRegistry;
|
|
50
|
+
/** Called with the ToolTimeoutError / handler-thrown error to bump counters. */
|
|
51
|
+
onError?: (toolName: string, err: unknown) => void;
|
|
52
|
+
/** Called per dispatch (success or failure) to bump call counters. */
|
|
53
|
+
onCall?: (toolName: string) => void;
|
|
54
|
+
/** Engine label, e.g. "rust" or "ts". Surfaced in `_meta` and dev stats. */
|
|
55
|
+
engineLabel?: () => string;
|
|
56
|
+
/**
|
|
57
|
+
* Whether `devOnly` tools are callable. Evaluated PER DISPATCH, not once at
|
|
58
|
+
* construction, so flipping the env in a test takes effect immediately.
|
|
59
|
+
*
|
|
60
|
+
* Omitted, dev-only tools stay callable — which is what this dispatcher did
|
|
61
|
+
* before the option existed, so adding it changes nothing until you pass it.
|
|
62
|
+
*
|
|
63
|
+
* The gap it closes: `devOnly` was honoured only by `toMcpTools()`, so the
|
|
64
|
+
* tool was hidden from `tools/list` and still executed if you named it
|
|
65
|
+
* anyway, and every non-MCP caller (a CLI, a REPL tool list) bypassed the
|
|
66
|
+
* filter entirely. **Hiding a tool is not disabling it.** Found and fixed in
|
|
67
|
+
* browser-tab-mcp's vendored copy of this file.
|
|
68
|
+
*/
|
|
69
|
+
devOnlyEnabled?: () => boolean;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export type Dispatch = (name: string, args: unknown, signal?: AbortSignal) => Promise<ToolResult>;
|
|
73
|
+
|
|
74
|
+
function defaultTimeoutMs(): number {
|
|
75
|
+
return envNum("MCP_TOOL_TIMEOUT_DEFAULT_MS", 30_000);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function formatZodError(err: ZodError): string {
|
|
79
|
+
return err.errors.map((e) => ` - ${e.path.join(".") || "(root)"}: ${e.message}`).join("\n");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function buildDispatcher(opts: BuildDispatcherOptions): Dispatch {
|
|
83
|
+
const fallback = defaultTimeoutMs();
|
|
84
|
+
return async (name, args, signal) => {
|
|
85
|
+
noteActivity();
|
|
86
|
+
opts.onCall?.(name);
|
|
87
|
+
const def = opts.registry.get(name);
|
|
88
|
+
|
|
89
|
+
// A dev-only tool that is not enabled must be INDISTINGUISHABLE from one
|
|
90
|
+
// that does not exist. A distinct "disabled" error confirms the tool is
|
|
91
|
+
// there, which is the thing a gate exists to avoid.
|
|
92
|
+
const devGated =
|
|
93
|
+
def?.devOnly === true && opts.devOnlyEnabled !== undefined ? !opts.devOnlyEnabled() : false;
|
|
94
|
+
|
|
95
|
+
if (!def || devGated) {
|
|
96
|
+
opts.onError?.(name, new Error("unknown_tool"));
|
|
97
|
+
return {
|
|
98
|
+
content: [
|
|
99
|
+
{
|
|
100
|
+
type: "text",
|
|
101
|
+
text: wrapToolError(name, "Unknown tool name.", "Check the result of tools/list."),
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
isError: true,
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const parsed = def.input.safeParse(args ?? {});
|
|
109
|
+
if (!parsed.success) {
|
|
110
|
+
opts.onError?.(name, parsed.error);
|
|
111
|
+
return {
|
|
112
|
+
content: [
|
|
113
|
+
{
|
|
114
|
+
type: "text",
|
|
115
|
+
text: wrapToolError(
|
|
116
|
+
name,
|
|
117
|
+
"Invalid arguments:",
|
|
118
|
+
`\n${formatZodError(parsed.error as ZodError)}`,
|
|
119
|
+
),
|
|
120
|
+
},
|
|
121
|
+
],
|
|
122
|
+
isError: true,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const span = perf(`dispatch.${name}`);
|
|
127
|
+
const timeoutMs = def.timeoutMs ?? fallback;
|
|
128
|
+
|
|
129
|
+
try {
|
|
130
|
+
const result = await withTimeout(name, () => def.handler(parsed.data, signal), timeoutMs);
|
|
131
|
+
const dur = span.end({ engine: opts.engineLabel?.() ?? "ts" });
|
|
132
|
+
const textBlock: ContentBlock = {
|
|
133
|
+
type: "text",
|
|
134
|
+
text: typeof result === "string" ? result : JSON.stringify(result, null, 2),
|
|
135
|
+
};
|
|
136
|
+
// Media blocks lead, the JSON summary follows: a screenshot tool returns
|
|
137
|
+
// `[image, text]`. Renderers depend on that order — cli-kit's prints the
|
|
138
|
+
// image line above the payload — so it is a contract, not a detail.
|
|
139
|
+
let extra: ContentBlock[] = [];
|
|
140
|
+
if (def.toContent) {
|
|
141
|
+
try {
|
|
142
|
+
extra = def.toContent(result);
|
|
143
|
+
} catch (err) {
|
|
144
|
+
logWarn(`to_content_failed: ${name}`, { message: (err as Error)?.message });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
content: [...extra, textBlock],
|
|
149
|
+
structuredContent: result,
|
|
150
|
+
_meta: {
|
|
151
|
+
engine: opts.engineLabel?.() ?? "ts",
|
|
152
|
+
duration_ms: Math.round(dur * 10) / 10,
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
} catch (err) {
|
|
156
|
+
span.end({ error: true });
|
|
157
|
+
opts.onError?.(name, err);
|
|
158
|
+
logError(`dispatch_error: ${name}`, {
|
|
159
|
+
message: (err as Error)?.message,
|
|
160
|
+
stack: (err as Error)?.stack,
|
|
161
|
+
});
|
|
162
|
+
const message =
|
|
163
|
+
err instanceof ToolTimeoutError
|
|
164
|
+
? `Timed out after ${err.timeoutMs}ms. The server has unblocked; the underlying work may still be running.`
|
|
165
|
+
: ((err as Error)?.message ?? String(err));
|
|
166
|
+
return {
|
|
167
|
+
content: [{ type: "text", text: wrapToolError(name, message) }],
|
|
168
|
+
isError: true,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|