toolnexus 0.24.0 → 0.25.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 (55) hide show
  1. package/README.md +116 -1
  2. package/dist/acp.d.ts +37 -6
  3. package/dist/acp.js +239 -52
  4. package/dist/client.d.ts +32 -8
  5. package/dist/client.js +102 -23
  6. package/dist/index.d.ts +2 -0
  7. package/dist/index.js +2 -0
  8. package/dist/mcp.d.ts +32 -7
  9. package/dist/mcp.js +125 -36
  10. package/dist/mcp2026/auth.d.ts +137 -0
  11. package/dist/mcp2026/auth.js +446 -0
  12. package/dist/mcp2026/client.d.ts +48 -0
  13. package/dist/mcp2026/client.js +160 -0
  14. package/dist/mcp2026/protocol.d.ts +54 -0
  15. package/dist/mcp2026/protocol.js +113 -0
  16. package/dist/mcp2026/skills.d.ts +5 -0
  17. package/dist/mcp2026/skills.js +25 -0
  18. package/dist/mcp2026/source.d.ts +86 -0
  19. package/dist/mcp2026/source.js +372 -0
  20. package/dist/mcp2026/stdio.d.ts +24 -0
  21. package/dist/mcp2026/stdio.js +99 -0
  22. package/dist/mcp2026/toolsearch.d.ts +5 -0
  23. package/dist/mcp2026/toolsearch.js +58 -0
  24. package/dist/mcphandler.d.ts +169 -0
  25. package/dist/mcphandler.js +1145 -0
  26. package/dist/mcpheader.d.ts +5 -0
  27. package/dist/mcpheader.js +18 -0
  28. package/dist/mcpserve.d.ts +24 -15
  29. package/dist/mcpserve.js +105 -2
  30. package/dist/mcpserverauth.d.ts +48 -0
  31. package/dist/mcpserverauth.js +203 -0
  32. package/dist/skill.d.ts +21 -0
  33. package/dist/skill.js +44 -0
  34. package/dist/toolkit.d.ts +20 -1
  35. package/dist/toolkit.js +25 -3
  36. package/dist/types.d.ts +22 -0
  37. package/package.json +4 -3
  38. package/src/acp.ts +258 -55
  39. package/src/client.ts +124 -27
  40. package/src/index.ts +2 -0
  41. package/src/mcp.ts +162 -43
  42. package/src/mcp2026/auth.ts +473 -0
  43. package/src/mcp2026/client.ts +161 -0
  44. package/src/mcp2026/protocol.ts +135 -0
  45. package/src/mcp2026/skills.ts +22 -0
  46. package/src/mcp2026/source.ts +407 -0
  47. package/src/mcp2026/stdio.ts +102 -0
  48. package/src/mcp2026/toolsearch.ts +56 -0
  49. package/src/mcphandler.ts +1252 -0
  50. package/src/mcpheader.ts +19 -0
  51. package/src/mcpserve.ts +115 -2
  52. package/src/mcpserverauth.ts +216 -0
  53. package/src/skill.ts +50 -0
  54. package/src/toolkit.ts +31 -4
  55. package/src/types.ts +23 -0
@@ -0,0 +1,1252 @@
1
+ /**
2
+ * MCP — INBOUND, embeddable: a plain streamable-HTTP MCP server as a Node request
3
+ * listener `(req, res) => void` with generic per-request hooks (SPEC §7C.1, ADR 0039).
4
+ *
5
+ * `serve(addr, {mcp})` (mcpserve.ts) owns a listener and exposes one static tool list.
6
+ * `toolkit.mcpHandler(cfg)` is what a host mounts on its own server / router, behind its
7
+ * own TLS, CORS and OAuth. Every policy is a hook; toolnexus has no notion of tenant,
8
+ * user, role or storage — the host decides those inside the hooks.
9
+ *
10
+ * The transport is hand-written JSON-RPC (mirrors golang/mcphandler.go): tools resolve per
11
+ * request, and a confirm elicitation rides the SAME POST's SSE response. No SDK server
12
+ * types appear in this public API.
13
+ */
14
+ import { createHash, randomBytes } from "node:crypto"
15
+ import type { IncomingMessage, ServerResponse } from "node:http"
16
+ import type { Answer, Request, Tool, ToolAnnotations, ToolContext, ToolResult, ToolSource } from "./types.js"
17
+ import { pendingOf } from "./types.js"
18
+ import { toolResultToMcp } from "./mcpserve.js"
19
+ import { compareCodePoints } from "./order.js"
20
+ import { decodeHeaderValue } from "./mcpheader.js"
21
+ import type { McpSkill } from "./skill.js"
22
+
23
+ /** The MCP 2026-07-28 protocol version: an opt-in stateless wire (SPEC §7C.2), never a default. */
24
+ export const MCP_PROTOCOL_2026 = "2026-07-28"
25
+ /** The SEP-2640 skills extension id advertised by `server/discover` when `listSkills` is set. */
26
+ export const MCP_SKILLS_EXTENSION = "io.modelcontextprotocol/skills"
27
+ const META_VERSION = "io.modelcontextprotocol/protocolVersion"
28
+ const META_CLIENT_CAPS = "io.modelcontextprotocol/clientCapabilities"
29
+ const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"
30
+
31
+ /**
32
+ * The MCP protocol versions whose wire this handler implements (ADR 0039 §versions).
33
+ * 2026-07-28 is opt-in only (list it in `protocolVersions`): the stateless wire of SPEC §7C.2.
34
+ */
35
+ export const MCP_HANDLER_IMPLEMENTED_VERSIONS: readonly string[] = [MCP_PROTOCOL_2026, "2025-11-25", "2025-06-18"]
36
+ const DEFAULT_VERSIONS = ["2025-06-18"]
37
+ const DEFAULT_NAME = "toolnexus"
38
+ const DEFAULT_VERSION = "0.1.0"
39
+
40
+ export type McpDecisionKind = "allow" | "deny" | "confirm"
41
+
42
+ /**
43
+ * What `beforeCall` returns. `undefined` / `{}` allows. deny ⇒ `result` when set, else isError
44
+ * `message` (default "denied"). confirm ⇒ elicitation when the client can, else `result` when
45
+ * set, else isError "confirmation required: <message>".
46
+ */
47
+ export interface McpDecision {
48
+ kind?: McpDecisionKind
49
+ message?: string
50
+ result?: ToolResult
51
+ }
52
+
53
+ export interface McpServerInfo {
54
+ name?: string
55
+ /** serverInfo.title; empty ⇒ omitted. */
56
+ title?: string
57
+ version?: string
58
+ instructions?: string
59
+ }
60
+
61
+ export interface McpResource {
62
+ uri: string
63
+ name: string
64
+ title?: string
65
+ description?: string
66
+ mimeType?: string
67
+ }
68
+
69
+ /** One entry of resources/read: set `text`, or `blob` (base64). */
70
+ export interface McpResourceContent {
71
+ uri: string
72
+ mimeType?: string
73
+ text?: string
74
+ blob?: string
75
+ }
76
+
77
+ export interface McpPromptArgument {
78
+ name: string
79
+ description?: string
80
+ required?: boolean
81
+ }
82
+
83
+ export interface McpPrompt {
84
+ name: string
85
+ title?: string
86
+ description?: string
87
+ arguments?: McpPromptArgument[]
88
+ }
89
+
90
+ export interface McpPromptMessage {
91
+ role: "user" | "assistant"
92
+ text: string
93
+ }
94
+
95
+ export interface McpPromptResult {
96
+ description?: string
97
+ messages: McpPromptMessage[]
98
+ }
99
+
100
+ /** The inbound request a hook or tool is serving. Read it with the `*From(ctx)` helpers. */
101
+ export interface McpContext {
102
+ /** What `authenticate` returned (undefined without one). */
103
+ identity?: unknown
104
+ request: IncomingMessage
105
+ /** Mcp-Session-Id ("" when stateless / none). */
106
+ sessionId: string
107
+ /** The JSON-RPC request's `params._meta`, when present. */
108
+ meta?: Record<string, unknown>
109
+ /** Aborted when the HTTP request goes away. */
110
+ signal: AbortSignal
111
+ }
112
+
113
+ type Awaitable<T> = T | Promise<T>
114
+
115
+ /** Issues and checks Mcp-Session-Id values. Default: an in-memory set. */
116
+ export interface McpSessionIds {
117
+ generate(ctx: McpContext): Awaitable<string>
118
+ /** false ⇒ HTTP 404. */
119
+ validate(ctx: McpContext, id: string): Awaitable<boolean>
120
+ terminate(ctx: McpContext, id: string): Awaitable<void>
121
+ }
122
+
123
+ /**
124
+ * An `authenticate` failure with an explicit HTTP status and challenge (`McpServerAuth` throws
125
+ * it; any hook may). On any wire the handler answers `status` (default 401) with
126
+ * `WWW-Authenticate` = `challenge` (a non-empty `wwwAuthenticate` result wins) and the body
127
+ * `{"error":"unauthorized"}`. `reason` is never sent on the wire.
128
+ */
129
+ export class McpAuthError extends Error {
130
+ readonly status: number
131
+ readonly challenge: string
132
+ readonly reason: string
133
+ constructor(status: number, challenge: string, reason = "") {
134
+ super(reason || "unauthorized")
135
+ this.name = "McpAuthError"
136
+ this.status = status
137
+ this.challenge = challenge
138
+ this.reason = reason
139
+ }
140
+ }
141
+
142
+ /** Surfaced once per inbound tools/call. */
143
+ export interface McpCallEvent {
144
+ name: string
145
+ source: ToolSource
146
+ ms: number
147
+ isError: boolean
148
+ /** The execute() failure, when it threw. */
149
+ err?: unknown
150
+ /** Size in bytes of the JSON CallToolResult sent back. */
151
+ resultBytes: number
152
+ decision: McpDecisionKind
153
+ identity: unknown
154
+ sessionId: string
155
+ }
156
+
157
+ /** Configures `toolkit.mcpHandler`. Every field is optional. */
158
+ export interface McpHandlerConfig {
159
+ name?: string
160
+ title?: string
161
+ version?: string
162
+ instructions?: string
163
+ /** Resolved at initialize; wins over the static fields. */
164
+ serverInfo?: (ctx: McpContext) => Awaitable<McpServerInfo>
165
+ /** Accepted versions, newest first; unimplemented entries dropped. Default ["2025-06-18"]. */
166
+ protocolVersions?: string[]
167
+ /** Every HTTP request, before JSON-RPC parsing. Throw / reject ⇒ HTTP 401. */
168
+ authenticate?: (req: IncomingMessage) => Awaitable<unknown>
169
+ /** WWW-Authenticate header for the 401 (when non-empty). */
170
+ wwwAuthenticate?: (req: IncomingMessage, err: unknown) => string | undefined
171
+ /** Per request, for tools/list AND the tools/call lookup. Omit ⇒ the toolkit's tools. */
172
+ listTools?: (ctx: McpContext) => Awaitable<Tool[]>
173
+ /** Gate per tools/call. A throw is a deny with its message. */
174
+ beforeCall?: (ctx: McpContext, tool: Tool, args: Record<string, unknown>) => Awaitable<McpDecision | void>
175
+ /** Elicitation round-trip bound in ms. Default 5 minutes. */
176
+ confirmTimeout?: number
177
+ /** Fires once per tools/call; its throws are isolated. */
178
+ onCall?: (ev: McpCallEvent) => void
179
+ listResources?: (ctx: McpContext) => Awaitable<McpResource[]>
180
+ readResource?: (ctx: McpContext, uri: string) => Awaitable<McpResourceContent[]>
181
+ listPrompts?: (ctx: McpContext) => Awaitable<McpPrompt[]>
182
+ getPrompt?: (ctx: McpContext, name: string, args: Record<string, string>) => Awaitable<McpPromptResult>
183
+ /**
184
+ * §7C.2: skills published through the SEP-2640 skills extension on the 2026-07-28 wire
185
+ * (skills/list, skills/get, resources/read of skill:// URIs). Omit ⇒ not advertised.
186
+ * `loadMcpSkills(dir)` builds the list from a skills directory.
187
+ */
188
+ listSkills?: (ctx: McpContext) => Awaitable<McpSkill[]>
189
+ sessionIds?: McpSessionIds
190
+ /** No Mcp-Session-Id issued or checked; elicitation never used. */
191
+ stateless?: boolean
192
+ /** Stateless only: echoed as Mcp-Session-Id on initialize, never validated. */
193
+ statelessSessionId?: string
194
+ /** POST body cap in bytes. Default 4 MiB. */
195
+ maxBodyBytes?: number
196
+ }
197
+
198
+ /** The embeddable handler: a Node request listener plus `notifyToolsChanged()`. */
199
+ export interface McpHandler {
200
+ (req: IncomingMessage, res: ServerResponse): void
201
+ /** Sends notifications/tools/list_changed on every open GET stream. */
202
+ notifyToolsChanged(): void
203
+ }
204
+
205
+ type AnyCtx = McpContext | ToolContext | undefined | null
206
+ function mcpOf(ctx: AnyCtx): McpContext | undefined {
207
+ if (!ctx) return undefined
208
+ if ((ctx as ToolContext).mcp) return (ctx as ToolContext).mcp
209
+ if ((ctx as McpContext).request && "sessionId" in ctx) return ctx as McpContext
210
+ return undefined
211
+ }
212
+ /** The value `authenticate` produced for this request (hook ctx or tool ctx). */
213
+ export const identityFrom = (ctx: AnyCtx): unknown => mcpOf(ctx)?.identity
214
+ /** The inbound IncomingMessage being served. */
215
+ export const requestFrom = (ctx: AnyCtx): IncomingMessage | undefined => mcpOf(ctx)?.request
216
+ /** The request's Mcp-Session-Id ("" if none). */
217
+ export const mcpSessionIdFrom = (ctx: AnyCtx): string => mcpOf(ctx)?.sessionId ?? ""
218
+ /** The JSON-RPC request's `params._meta` (undefined when absent). */
219
+ export const mcpRequestMetaFrom = (ctx: AnyCtx): Record<string, unknown> | undefined => mcpOf(ctx)?.meta
220
+
221
+ interface RpcError {
222
+ code: number
223
+ message: string
224
+ }
225
+ const rpcErr = (code: number, message: string): { error: RpcError } => ({ error: { code, message } })
226
+ const errMsg = (e: unknown) => (e instanceof Error ? e.message : String(e))
227
+ const isObj = (v: unknown): v is Record<string, any> => typeof v === "object" && v !== null && !Array.isArray(v)
228
+
229
+ function omitEmpty(m: Record<string, unknown>): Record<string, unknown> {
230
+ for (const k of Object.keys(m)) if (m[k] === "" || m[k] === undefined) delete m[k]
231
+ return m
232
+ }
233
+
234
+ function httpError(res: ServerResponse, status: number, text: string, headers: Record<string, string> = {}) {
235
+ res.writeHead(status, { "Content-Type": "text/plain; charset=utf-8", "X-Content-Type-Options": "nosniff", ...headers })
236
+ res.end(text + "\n")
237
+ }
238
+
239
+ function writeAuthFailure(cfg: McpHandlerConfig, req: IncomingMessage, res: ServerResponse, err: unknown) {
240
+ let challenge = cfg.wwwAuthenticate?.(req, err) ?? ""
241
+ if (err instanceof McpAuthError) {
242
+ if (!challenge) challenge = err.challenge
243
+ const h: Record<string, string> = { "Content-Type": "application/json" }
244
+ if (challenge) h["WWW-Authenticate"] = challenge
245
+ res.writeHead(err.status || 401, h)
246
+ res.end('{"error":"unauthorized"}')
247
+ return
248
+ }
249
+ httpError(res, 401, "Unauthorized", challenge ? { "WWW-Authenticate": challenge } : {})
250
+ }
251
+
252
+ function writeRpcError(res: ServerResponse, id: unknown, code: number, message: string) {
253
+ res.writeHead(400, { "Content-Type": "application/json" })
254
+ res.end(JSON.stringify({ jsonrpc: "2.0", id: id ?? null, error: { code, message } }) + "\n")
255
+ }
256
+
257
+ /** The tools/list entry: name, description, inputSchema, outputSchema?, annotations?. */
258
+ export function mcpWireTool(t: Tool): Record<string, unknown> {
259
+ const m: Record<string, unknown> = {
260
+ name: t.name,
261
+ description: t.description,
262
+ inputSchema: t.inputSchema ?? { type: "object" },
263
+ }
264
+ if (t.outputSchema) m.outputSchema = t.outputSchema
265
+ const ann: Record<string, unknown> = {}
266
+ const a: ToolAnnotations | undefined = t.annotations
267
+ if (a) {
268
+ if (a.title) ann.title = a.title
269
+ for (const k of ["readOnlyHint", "destructiveHint", "idempotentHint", "openWorldHint"] as const) {
270
+ if (typeof a[k] === "boolean") ann[k] = a[k]
271
+ }
272
+ } else if (t.hints) {
273
+ ann.readOnlyHint = t.hints.readOnly
274
+ ann.destructiveHint = t.hints.destructive
275
+ ann.openWorldHint = t.hints.openWorld
276
+ }
277
+ if (Object.keys(ann).length > 0) m.annotations = ann
278
+ return m
279
+ }
280
+
281
+ function memorySessionIds(): McpSessionIds {
282
+ const live = new Set<string>()
283
+ return {
284
+ generate() {
285
+ const id = randomBytes(16).toString("hex")
286
+ live.add(id)
287
+ return id
288
+ },
289
+ validate: (_ctx, id) => live.has(id),
290
+ terminate(_ctx, id) {
291
+ live.delete(id)
292
+ },
293
+ }
294
+ }
295
+
296
+ /** Answers one POST: plain JSON, or SSE once a server→client request was sent first. */
297
+ class RpcWriter {
298
+ sse = false
299
+ constructor(private readonly res: ServerResponse) {}
300
+ event(v: unknown) {
301
+ if (!this.sse) {
302
+ this.res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" })
303
+ this.sse = true
304
+ }
305
+ this.res.write(`event: message\ndata: ${JSON.stringify(v)}\n\n`)
306
+ }
307
+ respond(id: unknown, out: { result?: unknown; error?: RpcError }) {
308
+ const msg: Record<string, unknown> = { jsonrpc: "2.0", id }
309
+ if (out.error) msg.error = out.error
310
+ else msg.result = out.result
311
+ if (this.sse) {
312
+ this.event(msg)
313
+ this.res.end()
314
+ return
315
+ }
316
+ this.res.writeHead(200, { "Content-Type": "application/json" })
317
+ this.res.end(JSON.stringify(msg) + "\n")
318
+ }
319
+ }
320
+
321
+ /** Builds the handler (`toolkit.mcpHandler(cfg)` calls this with the toolkit's tools). */
322
+ export function createMcpHandler(toolkitTools: () => Tool[], cfg: McpHandlerConfig = {}): McpHandler {
323
+ const versions: string[] = []
324
+ for (const v of cfg.protocolVersions ?? []) {
325
+ if (MCP_HANDLER_IMPLEMENTED_VERSIONS.includes(v) && !versions.includes(v)) versions.push(v)
326
+ }
327
+ if (versions.length === 0) versions.push(...DEFAULT_VERSIONS)
328
+ /** The initialize/session wire: every accepted version but 2026-07-28. */
329
+ const legacy = versions.filter((v) => v !== MCP_PROTOCOL_2026)
330
+ const has2026 = versions.includes(MCP_PROTOCOL_2026)
331
+ const only2026 = has2026 && legacy.length === 0
332
+ const ids = cfg.sessionIds ?? memorySessionIds()
333
+ const confirmTimeout = cfg.confirmTimeout && cfg.confirmTimeout > 0 ? cfg.confirmTimeout : 5 * 60_000
334
+ const maxBody = cfg.maxBodyBytes && cfg.maxBodyBytes > 0 ? cfg.maxBodyBytes : 4 << 20
335
+
336
+ const caps = new Map<string, boolean>()
337
+ const streams = new Set<ServerResponse>()
338
+ const pending = new Map<string, (msg: Record<string, any>) => void>()
339
+ let seq = 0
340
+
341
+ const header = (req: IncomingMessage, name: string): string => {
342
+ const v = req.headers[name.toLowerCase()]
343
+ return Array.isArray(v) ? (v[0] ?? "") : (v ?? "")
344
+ }
345
+
346
+ const tools = async (ctx: McpContext) => (cfg.listTools ? await cfg.listTools(ctx) : toolkitTools())
347
+
348
+ /** Validates protocol header + session (stateful). Writes the HTTP error itself on failure. */
349
+ async function checkSession(ctx: McpContext, req: IncomingMessage, res: ServerResponse): Promise<boolean> {
350
+ const v = header(req, "MCP-Protocol-Version")
351
+ if (v !== "" && !legacy.includes(v)) {
352
+ httpError(res, 400, "Bad Request: unsupported MCP-Protocol-Version")
353
+ return false
354
+ }
355
+ if (cfg.stateless) return true
356
+ const sid = header(req, "Mcp-Session-Id")
357
+ if (sid === "") {
358
+ httpError(res, 400, "Bad Request: missing Mcp-Session-Id")
359
+ return false
360
+ }
361
+ let ok = false
362
+ try {
363
+ ok = await ids.validate(ctx, sid)
364
+ } catch {
365
+ ok = false
366
+ }
367
+ if (!ok) {
368
+ httpError(res, 404, "Session not found")
369
+ return false
370
+ }
371
+ ctx.sessionId = sid
372
+ return true
373
+ }
374
+
375
+ function readBody(req: IncomingMessage): Promise<{ body?: string; tooLarge?: boolean; error?: boolean }> {
376
+ return new Promise((resolve) => {
377
+ const chunks: Buffer[] = []
378
+ let size = 0
379
+ let tooLarge = false
380
+ req.on("data", (c: Buffer) => {
381
+ size += c.length
382
+ if (size > maxBody) tooLarge = true
383
+ else chunks.push(c)
384
+ })
385
+ req.on("end", () => resolve(tooLarge ? { tooLarge } : { body: Buffer.concat(chunks).toString("utf8") }))
386
+ req.on("error", () => resolve({ error: true }))
387
+ })
388
+ }
389
+
390
+ type ReadResult = Awaited<ReturnType<typeof readBody>>
391
+ const isJsonPost = (req: IncomingMessage) => header(req, "Content-Type").split(";")[0]!.trim().toLowerCase() === "application/json"
392
+
393
+ async function handlePost(ctx: McpContext, req: IncomingMessage, res: ServerResponse, pre?: ReadResult) {
394
+ if (!isJsonPost(req)) {
395
+ httpError(res, 415, "Unsupported Media Type: must be application/json")
396
+ return
397
+ }
398
+ const rb = pre ?? (await readBody(req))
399
+ if (rb.error) return writeRpcError(res, null, -32700, "read error")
400
+ if (rb.tooLarge) return httpError(res, 413, "Request Entity Too Large")
401
+ const body = rb.body!
402
+ if (body.trim().startsWith("[")) {
403
+ // JSON-RPC batching was removed in protocol 2025-06-18.
404
+ return writeRpcError(res, null, -32600, "batch requests are not supported")
405
+ }
406
+ let msg: Record<string, any>
407
+ try {
408
+ msg = JSON.parse(body)
409
+ } catch {
410
+ return writeRpcError(res, null, -32700, "parse error")
411
+ }
412
+ if (!isObj(msg) || msg.jsonrpc !== "2.0" || (msg.method !== undefined && typeof msg.method !== "string")) {
413
+ return writeRpcError(res, null, -32700, "parse error")
414
+ }
415
+ const method: string = msg.method ?? ""
416
+ const hasId = "id" in msg
417
+
418
+ if (method === "initialize") return handleInitialize(ctx, res, msg)
419
+
420
+ if (!(await checkSession(ctx, req, res))) return
421
+
422
+ if (method === "") {
423
+ // A client response (the answer to our elicitation).
424
+ if (hasId && typeof msg.id === "string") pending.get(ctx.sessionId + "\0" + msg.id)?.(msg)
425
+ res.writeHead(202).end()
426
+ return
427
+ }
428
+ if (!hasId || msg.id === null) {
429
+ res.writeHead(202).end() // a notification
430
+ return
431
+ }
432
+ if (isObj(msg.params) && isObj(msg.params._meta)) ctx.meta = msg.params._meta
433
+ const rw = new RpcWriter(res)
434
+ let out: { result?: unknown; error?: RpcError }
435
+ try {
436
+ out = await dispatch(ctx, rw, method, msg.params)
437
+ } catch (e) {
438
+ out = rpcErr(-32603, errMsg(e))
439
+ }
440
+ rw.respond(msg.id, out)
441
+ }
442
+
443
+ async function handleInitialize(ctx: McpContext, res: ServerResponse, msg: Record<string, any>) {
444
+ const p = isObj(msg.params) ? msg.params : {}
445
+ const version = typeof p.protocolVersion === "string" && legacy.includes(p.protocolVersion) ? p.protocolVersion : legacy[0]!
446
+ let sid = ""
447
+ if (cfg.stateless) {
448
+ sid = cfg.statelessSessionId ?? ""
449
+ } else {
450
+ try {
451
+ sid = await ids.generate(ctx)
452
+ } catch {
453
+ return writeRpcError(res, msg.id, -32603, "cannot create session")
454
+ }
455
+ ctx.sessionId = sid
456
+ const el = isObj(p.capabilities) ? p.capabilities.elicitation : undefined
457
+ caps.set(sid, el !== undefined && el !== null)
458
+ }
459
+ const info: McpServerInfo = cfg.serverInfo
460
+ ? await cfg.serverInfo(ctx)
461
+ : { name: cfg.name, title: cfg.title, version: cfg.version, instructions: cfg.instructions }
462
+ const capabilities: Record<string, unknown> = { tools: { listChanged: true } }
463
+ if (cfg.listResources) capabilities.resources = {}
464
+ if (cfg.listPrompts) capabilities.prompts = {}
465
+ const result: Record<string, unknown> = {
466
+ protocolVersion: version,
467
+ capabilities,
468
+ serverInfo: omitEmpty({ name: info.name || DEFAULT_NAME, title: info.title, version: info.version || DEFAULT_VERSION }),
469
+ }
470
+ if (info.instructions) result.instructions = info.instructions
471
+ if (sid !== "") res.setHeader("Mcp-Session-Id", sid)
472
+ new RpcWriter(res).respond(msg.id, { result })
473
+ }
474
+
475
+ async function handleGet(ctx: McpContext, req: IncomingMessage, res: ServerResponse) {
476
+ if (!header(req, "Accept").includes("text/event-stream")) return httpError(res, 405, "Method Not Allowed")
477
+ if (!(await checkSession(ctx, req, res))) return
478
+ res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache" })
479
+ res.flushHeaders()
480
+ streams.add(res)
481
+ res.on("close", () => streams.delete(res))
482
+ }
483
+
484
+ async function handleDelete(ctx: McpContext, req: IncomingMessage, res: ServerResponse) {
485
+ if (cfg.stateless) return httpError(res, 405, "Method Not Allowed")
486
+ if (!(await checkSession(ctx, req, res))) return
487
+ try {
488
+ await ids.terminate(ctx, ctx.sessionId)
489
+ } catch {
490
+ return httpError(res, 500, "cannot terminate session")
491
+ }
492
+ caps.delete(ctx.sessionId)
493
+ res.writeHead(200).end()
494
+ }
495
+
496
+ async function dispatch(
497
+ ctx: McpContext,
498
+ rw: RpcWriter,
499
+ method: string,
500
+ params: any,
501
+ ): Promise<{ result?: unknown; error?: RpcError }> {
502
+ switch (method) {
503
+ case "ping":
504
+ return { result: {} }
505
+ case "tools/list": {
506
+ let ts: Tool[]
507
+ try {
508
+ ts = await tools(ctx)
509
+ } catch (e) {
510
+ return rpcErr(-32603, errMsg(e))
511
+ }
512
+ return { result: { tools: (ts ?? []).map(mcpWireTool) } }
513
+ }
514
+ case "tools/call":
515
+ return callTool(ctx, rw, params)
516
+ case "resources/list": {
517
+ if (!cfg.listResources) break
518
+ let rs: McpResource[]
519
+ try {
520
+ rs = await cfg.listResources(ctx)
521
+ } catch (e) {
522
+ return rpcErr(-32603, errMsg(e))
523
+ }
524
+ return {
525
+ result: {
526
+ resources: (rs ?? []).map((r) =>
527
+ omitEmpty({ uri: r.uri, name: r.name, title: r.title, description: r.description, mimeType: r.mimeType }),
528
+ ),
529
+ },
530
+ }
531
+ }
532
+ case "resources/read": {
533
+ if (!cfg.listResources || !cfg.readResource) break
534
+ const uri = isObj(params) && typeof params.uri === "string" ? params.uri : ""
535
+ let cs: McpResourceContent[]
536
+ try {
537
+ cs = await cfg.readResource(ctx, uri)
538
+ } catch (e) {
539
+ return rpcErr(-32002, errMsg(e))
540
+ }
541
+ return {
542
+ result: {
543
+ contents: (cs ?? []).map((c) => {
544
+ const m = omitEmpty({ uri: c.uri, mimeType: c.mimeType, blob: c.blob })
545
+ if (!c.blob) m.text = c.text ?? ""
546
+ return m
547
+ }),
548
+ },
549
+ }
550
+ }
551
+ case "prompts/list": {
552
+ if (!cfg.listPrompts) break
553
+ let ps: McpPrompt[]
554
+ try {
555
+ ps = await cfg.listPrompts(ctx)
556
+ } catch (e) {
557
+ return rpcErr(-32603, errMsg(e))
558
+ }
559
+ return {
560
+ result: {
561
+ prompts: (ps ?? []).map((p) => {
562
+ const m = omitEmpty({ name: p.name, title: p.title, description: p.description })
563
+ if (p.arguments?.length) {
564
+ m.arguments = p.arguments.map((a) => {
565
+ const am = omitEmpty({ name: a.name, description: a.description })
566
+ if (a.required) am.required = true
567
+ return am
568
+ })
569
+ }
570
+ return m
571
+ }),
572
+ },
573
+ }
574
+ }
575
+ case "prompts/get": {
576
+ if (!cfg.listPrompts || !cfg.getPrompt) break
577
+ if (!isObj(params)) return rpcErr(-32602, "invalid params")
578
+ const { name, arguments: args } = params
579
+ const badArgs =
580
+ args !== undefined && args !== null && (!isObj(args) || Object.values(args).some((v) => typeof v !== "string"))
581
+ if ((name !== undefined && name !== null && typeof name !== "string") || badArgs) {
582
+ return rpcErr(-32602, "invalid params")
583
+ }
584
+ let pr: McpPromptResult
585
+ try {
586
+ pr = await cfg.getPrompt(ctx, name ?? "", (args as Record<string, string>) ?? {})
587
+ } catch (e) {
588
+ return rpcErr(-32602, errMsg(e))
589
+ }
590
+ return {
591
+ result: omitEmpty({
592
+ description: pr.description,
593
+ messages: (pr.messages ?? []).map((m) => ({ role: m.role, content: { type: "text", text: m.text } })),
594
+ }),
595
+ }
596
+ }
597
+ }
598
+ return rpcErr(-32601, "method not found: " + method)
599
+ }
600
+
601
+ async function callTool(ctx: McpContext, rw: RpcWriter, params: any): Promise<{ result?: unknown; error?: RpcError }> {
602
+ if (!isObj(params) || (params.name !== undefined && params.name !== null && typeof params.name !== "string")) {
603
+ return rpcErr(-32602, "invalid params")
604
+ }
605
+ const name: string = params.name ?? ""
606
+ let ts: Tool[]
607
+ try {
608
+ ts = await tools(ctx)
609
+ } catch (e) {
610
+ return rpcErr(-32603, errMsg(e))
611
+ }
612
+ const tool = (ts ?? []).find((t) => t.name === name)
613
+ if (!tool || typeof tool.execute !== "function") return rpcErr(-32602, "tool not found: " + name)
614
+
615
+ let args: Record<string, unknown> = {}
616
+ const raw = params.arguments
617
+ if (raw !== undefined && raw !== null) {
618
+ if (!isObj(raw)) {
619
+ if (header(ctx.request, "MCP-Protocol-Version") === "2025-11-25") {
620
+ // 2025-11-25: input validation errors are tool execution errors.
621
+ return { result: toolResultToMcp({ output: "invalid arguments: must be an object", isError: true }, true) }
622
+ }
623
+ return rpcErr(-32602, "arguments must be an object")
624
+ }
625
+ args = raw
626
+ }
627
+
628
+ const started = Date.now()
629
+ const ev: McpCallEvent = {
630
+ name: tool.name,
631
+ source: tool.source,
632
+ ms: 0,
633
+ isError: false,
634
+ resultBytes: 0,
635
+ decision: "allow",
636
+ identity: ctx.identity,
637
+ sessionId: ctx.sessionId,
638
+ }
639
+ const finish = (res: ToolResult, execErr?: unknown) => {
640
+ const wire = toolResultToMcp(res, true)
641
+ ev.ms = Date.now() - started
642
+ ev.isError = !!res.isError
643
+ if (execErr !== undefined) ev.err = execErr
644
+ ev.resultBytes = Buffer.byteLength(JSON.stringify(wire))
645
+ if (cfg.onCall) {
646
+ try {
647
+ cfg.onCall(ev)
648
+ } catch {
649
+ // host callback throws are isolated
650
+ }
651
+ }
652
+ return { result: wire }
653
+ }
654
+
655
+ if (cfg.beforeCall) {
656
+ let d: McpDecision
657
+ try {
658
+ d = (await cfg.beforeCall(ctx, tool, args)) ?? {}
659
+ } catch (e) {
660
+ d = { kind: "deny", message: errMsg(e) }
661
+ }
662
+ const kind: McpDecisionKind = d.kind ?? "allow"
663
+ ev.decision = kind
664
+ if (kind === "deny") {
665
+ if (d.result) return finish(d.result)
666
+ return finish({ output: d.message || "denied", isError: true })
667
+ }
668
+ if (kind === "confirm") {
669
+ if (!(ctx.sessionId !== "" && caps.get(ctx.sessionId))) {
670
+ if (d.result) return finish(d.result)
671
+ return finish({ output: "confirmation required: " + (d.message ?? ""), isError: true })
672
+ }
673
+ let accepted: boolean
674
+ try {
675
+ accepted = await elicitConfirm(ctx, rw, d.message ?? "")
676
+ } catch (e) {
677
+ return finish({ output: "confirmation failed: " + errMsg(e), isError: true })
678
+ }
679
+ if (!accepted) return finish({ output: "declined", isError: true })
680
+ }
681
+ }
682
+
683
+ let res: ToolResult
684
+ let execErr: unknown
685
+ try {
686
+ res = await tool.execute(args, { signal: ctx.signal, mcp: ctx })
687
+ } catch (e) {
688
+ execErr = e
689
+ res = { output: errMsg(e), isError: true }
690
+ }
691
+ return finish(res, execErr)
692
+ }
693
+
694
+ /** Sends elicitation/create on this POST's SSE stream; the answer arrives via a later POST. */
695
+ function elicitConfirm(ctx: McpContext, rw: RpcWriter, message: string): Promise<boolean> {
696
+ const id = `toolnexus-confirm-${++seq}`
697
+ const key = ctx.sessionId + "\0" + id
698
+ if (!message) message = "Confirm this action?"
699
+ return new Promise<boolean>((resolve, reject) => {
700
+ const cleanup = () => {
701
+ pending.delete(key)
702
+ clearTimeout(timer)
703
+ ctx.signal.removeEventListener("abort", onAbort)
704
+ }
705
+ const onAbort = () => {
706
+ cleanup()
707
+ reject(new Error("context canceled"))
708
+ }
709
+ const timer = setTimeout(() => {
710
+ cleanup()
711
+ reject(new Error("context deadline exceeded"))
712
+ }, confirmTimeout)
713
+ ctx.signal.addEventListener("abort", onAbort)
714
+ pending.set(key, (m) => {
715
+ cleanup()
716
+ if (m.error !== undefined && m.error !== null) return reject(new Error("client returned an error"))
717
+ if (m.result === undefined) return reject(new Error("unexpected end of JSON input"))
718
+ const r = isObj(m.result) ? m.result : {}
719
+ if (r.action !== "accept") return resolve(false)
720
+ if (isObj(r.content) && r.content.confirm === false) return resolve(false)
721
+ resolve(true)
722
+ })
723
+ try {
724
+ rw.event({
725
+ jsonrpc: "2.0",
726
+ id,
727
+ method: "elicitation/create",
728
+ params: {
729
+ message,
730
+ requestedSchema: {
731
+ type: "object",
732
+ properties: { confirm: { type: "boolean", title: "Confirm", description: message } },
733
+ },
734
+ },
735
+ })
736
+ } catch (e) {
737
+ cleanup()
738
+ reject(e)
739
+ }
740
+ })
741
+ }
742
+
743
+ // ---- MCP 2026-07-28 (SPEC §7C.2, ADR 0040): the stateless wire, opt-in ----
744
+
745
+ /** Routes and answers a POST on the 2026 profile; false ⇒ the legacy wire serves it (body pre-read). */
746
+ async function tryPost2026(ctx: McpContext, req: IncomingMessage, res: ServerResponse, rb: ReadResult): Promise<boolean> {
747
+ if (rb.error || rb.tooLarge) return false // the legacy path answers identically
748
+ const body = rb.body!
749
+ const trimmed = body.trim()
750
+ let msg: any
751
+ let bad = trimmed.startsWith("[")
752
+ if (!bad) {
753
+ try {
754
+ msg = JSON.parse(body)
755
+ } catch {
756
+ bad = true
757
+ }
758
+ }
759
+ if (!bad && (!isObj(msg) || msg.jsonrpc !== "2.0" || (msg.method !== undefined && msg.method !== null && typeof msg.method !== "string"))) bad = true
760
+ if (bad) {
761
+ if (!only2026) return false
762
+ if (trimmed.startsWith("[")) write26(res, 400, null, undefined, { code: -32600, message: "batch requests are not supported" })
763
+ else write26(res, 400, null, undefined, { code: -32700, message: "Parse error" })
764
+ return true
765
+ }
766
+ const method: string = msg.method ?? ""
767
+ const params = isObj(msg.params) ? msg.params : undefined
768
+ const meta = params && isObj(params._meta) ? (params._meta as Record<string, unknown>) : undefined
769
+ if (!route2026(req, method, meta)) return false
770
+ await serve2026(ctx, req, res, msg, method, params ?? {}, meta)
771
+ return true
772
+ }
773
+
774
+ function route2026(req: IncomingMessage, method: string, meta: Record<string, unknown> | undefined): boolean {
775
+ if (only2026) return true
776
+ if (method === "initialize") return false
777
+ const hv = header(req, "MCP-Protocol-Version")
778
+ if (hv === MCP_PROTOCOL_2026) return true
779
+ if (hv !== "" && versions.includes(hv)) return false
780
+ return method === "server/discover" || header(req, "Mcp-Method") !== "" || (meta !== undefined && META_VERSION in meta)
781
+ }
782
+
783
+ async function serve2026(
784
+ ctx: McpContext,
785
+ req: IncomingMessage,
786
+ res: ServerResponse,
787
+ msg: Record<string, any>,
788
+ method: string,
789
+ params: Record<string, any>,
790
+ meta: Record<string, unknown> | undefined,
791
+ ) {
792
+ if (method === "" || !("id" in msg) || msg.id === null) {
793
+ res.writeHead(202).end() // a notification (or a stray response): acknowledged
794
+ return
795
+ }
796
+ const v = validate2026(req, method, params, meta)
797
+ if (v) return write26(res, 400, msg.id, undefined, v)
798
+ if (cfg.authenticate) {
799
+ try {
800
+ ctx.identity = await cfg.authenticate(req)
801
+ } catch (err) {
802
+ return writeAuthFailure(cfg, req, res, err)
803
+ }
804
+ }
805
+ if (meta) ctx.meta = meta
806
+ let out: Out26
807
+ try {
808
+ out = await dispatch2026(ctx, method, params, meta)
809
+ } catch (e) {
810
+ out = { error: { code: -32603, message: errMsg(e) } }
811
+ }
812
+ write26(res, out.status ?? 200, msg.id, out.result, out.error)
813
+ }
814
+
815
+ function validate2026(
816
+ req: IncomingMessage,
817
+ method: string,
818
+ p: Record<string, any>,
819
+ meta: Record<string, unknown> | undefined,
820
+ ): Err26 | undefined {
821
+ const mismatch = (m: string): Err26 => ({ code: -32020, message: "Header mismatch: " + m })
822
+ const hv = header(req, "MCP-Protocol-Version")
823
+ if (hv === "") return mismatch("MCP-Protocol-Version header missing")
824
+ const hm = header(req, "Mcp-Method")
825
+ if (hm !== method) return mismatch(`Mcp-Method header value '${hm}' does not match body value '${method}'`)
826
+ if (method === "tools/call" || method === "prompts/get" || method === "resources/read") {
827
+ const want = typeof p[method === "resources/read" ? "uri" : "name"] === "string" ? p[method === "resources/read" ? "uri" : "name"] : ""
828
+ const [got, ok] = decodeHeaderValue(header(req, "Mcp-Name"))
829
+ if (!ok || got !== want) return mismatch(`Mcp-Name header value '${got}' does not match body value '${want}'`)
830
+ }
831
+ const mv = meta && typeof meta[META_VERSION] === "string" ? (meta[META_VERSION] as string) : ""
832
+ if (mv !== hv) return mismatch(`MCP-Protocol-Version header value '${hv}' does not match _meta value '${mv}'`)
833
+ if (mv !== MCP_PROTOCOL_2026) {
834
+ return { code: -32022, message: "Unsupported protocol version", data: { supported: [MCP_PROTOCOL_2026], requested: mv } }
835
+ }
836
+ return undefined
837
+ }
838
+
839
+ async function info2026(ctx: McpContext): Promise<McpServerInfo> {
840
+ const info: McpServerInfo = cfg.serverInfo
841
+ ? { ...(await cfg.serverInfo(ctx)) }
842
+ : { name: cfg.name, title: cfg.title, version: cfg.version, instructions: cfg.instructions }
843
+ if (!info.name) info.name = DEFAULT_NAME
844
+ if (!info.version) info.version = DEFAULT_VERSION
845
+ return info
846
+ }
847
+
848
+ /** Stamps resultType and _meta.serverInfo (set last, after any ToolResult.meta keys). */
849
+ async function complete26(ctx: McpContext, m: Record<string, unknown>, resultType = "complete"): Promise<Out26> {
850
+ const info = await info2026(ctx)
851
+ const meta: Record<string, unknown> = isObj(m._meta) ? { ...m._meta } : {}
852
+ delete meta[META_SERVER_INFO]
853
+ meta[META_SERVER_INFO] = omitEmpty({ name: info.name, title: info.title, version: info.version })
854
+ m._meta = meta
855
+ m.resultType = resultType
856
+ return { result: m }
857
+ }
858
+
859
+ const cacheScope26 = () => (cfg.authenticate ? "private" : "public")
860
+
861
+ async function skills26(ctx: McpContext): Promise<McpSkill[]> {
862
+ const ss = (await cfg.listSkills!(ctx)) ?? []
863
+ return [...ss].sort((a, b) => compareCodePoints(a.name, b.name))
864
+ }
865
+
866
+ async function dispatch2026(
867
+ ctx: McpContext,
868
+ method: string,
869
+ p: Record<string, any>,
870
+ meta: Record<string, unknown> | undefined,
871
+ ): Promise<Out26> {
872
+ const internal = (e: unknown): Out26 => ({ error: { code: -32603, message: errMsg(e) } })
873
+ const invalid = (m: string): Out26 => ({ error: { code: -32602, message: m } })
874
+ switch (method) {
875
+ case "server/discover": {
876
+ const info = await info2026(ctx)
877
+ const caps: Record<string, unknown> = { tools: {} }
878
+ if (cfg.listResources || cfg.listSkills) caps.resources = {}
879
+ if (cfg.listPrompts) caps.prompts = {}
880
+ if (cfg.listSkills) caps.extensions = { [MCP_SKILLS_EXTENSION]: {} }
881
+ const r: Record<string, unknown> = { supportedVersions: [MCP_PROTOCOL_2026], capabilities: caps, ttlMs: 3600000, cacheScope: "public" }
882
+ if (info.instructions) r.instructions = info.instructions
883
+ return complete26(ctx, r)
884
+ }
885
+ case "ping":
886
+ return complete26(ctx, {})
887
+ case "tools/list": {
888
+ let ts: Tool[]
889
+ try {
890
+ ts = (await tools(ctx)) ?? []
891
+ } catch (e) {
892
+ return internal(e)
893
+ }
894
+ const sorted = [...ts].sort((a, b) => compareCodePoints(a.name, b.name))
895
+ return complete26(ctx, { tools: sorted.map(mcpWireTool), ttlMs: 60000, cacheScope: cacheScope26() })
896
+ }
897
+ case "tools/call":
898
+ return callTool2026(ctx, p, meta)
899
+ case "skills/list": {
900
+ if (!cfg.listSkills) break
901
+ let ss: McpSkill[]
902
+ try {
903
+ ss = await skills26(ctx)
904
+ } catch (e) {
905
+ return internal(e)
906
+ }
907
+ const list = ss.map((s) => ({ uri: `skill://${s.name}/SKILL.md`, frontmatter: skillFrontmatter(s) }))
908
+ return complete26(ctx, { skills: list, ttlMs: 60000, cacheScope: cacheScope26() })
909
+ }
910
+ case "skills/get": {
911
+ if (!cfg.listSkills) break
912
+ const uri = typeof p.uri === "string" ? p.uri : ""
913
+ const parsed = parseSkillUri(uri)
914
+ if ("error" in parsed) return invalid("Invalid skill URI: " + parsed.error)
915
+ let ss: McpSkill[]
916
+ try {
917
+ ss = await skills26(ctx)
918
+ } catch (e) {
919
+ return internal(e)
920
+ }
921
+ const s = ss.find((x) => `skill://${x.name}/SKILL.md` === uri)
922
+ if (s) return complete26(ctx, { skill: skillEntry(s) })
923
+ return invalid("Unknown skill: " + uri)
924
+ }
925
+ case "resources/list": {
926
+ if (!cfg.listResources) break
927
+ const r = await dispatch(ctx, null as unknown as RpcWriter, method, p)
928
+ if (r.error) return { error: r.error }
929
+ return complete26(ctx, { ...(r.result as Record<string, unknown>) })
930
+ }
931
+ case "resources/read": {
932
+ const uri = typeof p.uri === "string" ? p.uri : ""
933
+ if (uri.startsWith("skill://") && cfg.listSkills) {
934
+ const parsed = parseSkillUri(uri)
935
+ if ("error" in parsed) return invalid("Invalid resource URI: " + parsed.error)
936
+ let ss: McpSkill[]
937
+ try {
938
+ ss = await skills26(ctx)
939
+ } catch (e) {
940
+ return internal(e)
941
+ }
942
+ for (const s of ss) {
943
+ if (s.name !== parsed.name) continue
944
+ const f = (s.files ?? []).find((x) => x.path === parsed.rel)
945
+ if (f) {
946
+ const mimeType = parsed.rel.endsWith(".md") ? "text/markdown" : "text/plain"
947
+ return complete26(ctx, { contents: [{ uri, mimeType, text: f.text }], ttlMs: 60000, cacheScope: cacheScope26() })
948
+ }
949
+ }
950
+ return invalid("Resource not found: " + uri)
951
+ }
952
+ if (!cfg.listResources || !cfg.readResource) break
953
+ const r = await dispatch(ctx, null as unknown as RpcWriter, method, p)
954
+ if (r.error) return { error: r.error }
955
+ return complete26(ctx, { ...(r.result as Record<string, unknown>) })
956
+ }
957
+ case "prompts/list":
958
+ case "prompts/get": {
959
+ if (!cfg.listPrompts) break
960
+ // Same payload as the legacy wire; only the envelope differs.
961
+ const r = await dispatch(ctx, null as unknown as RpcWriter, method, p)
962
+ if (r.error) return { error: r.error }
963
+ return complete26(ctx, isObj(r.result) ? { ...r.result } : {})
964
+ }
965
+ }
966
+ return { status: 404, error: { code: -32601, message: "Method not found: " + method } }
967
+ }
968
+
969
+ async function callTool2026(ctx: McpContext, p: Record<string, any>, meta: Record<string, unknown> | undefined): Promise<Out26> {
970
+ const name = typeof p.name === "string" ? p.name : ""
971
+ let ts: Tool[]
972
+ try {
973
+ ts = (await tools(ctx)) ?? []
974
+ } catch (e) {
975
+ return { error: { code: -32603, message: errMsg(e) } }
976
+ }
977
+ const tool = ts.find((t) => t.name === name)
978
+ if (!tool || typeof tool.execute !== "function") return { error: { code: -32602, message: "Unknown tool: " + name } }
979
+
980
+ const started = Date.now()
981
+ const ev: McpCallEvent = {
982
+ name: tool.name,
983
+ source: tool.source,
984
+ ms: 0,
985
+ isError: false,
986
+ resultBytes: 0,
987
+ decision: "allow",
988
+ identity: ctx.identity,
989
+ sessionId: "",
990
+ }
991
+ const finish = (out: Out26, isError: boolean, execErr?: unknown): Out26 => {
992
+ ev.ms = Date.now() - started
993
+ ev.isError = isError
994
+ if (execErr !== undefined) ev.err = execErr
995
+ ev.resultBytes = Buffer.byteLength(JSON.stringify(out.result))
996
+ if (cfg.onCall) {
997
+ try {
998
+ cfg.onCall(ev)
999
+ } catch {
1000
+ // host callback throws are isolated
1001
+ }
1002
+ }
1003
+ return out
1004
+ }
1005
+ const result = async (r: ToolResult, execErr?: unknown) =>
1006
+ finish(await complete26(ctx, toolResultToMcp(r, true)), !!r.isError, execErr)
1007
+ const inputRequired = async (m: Record<string, unknown>) => finish(await complete26(ctx, m, "input_required"), false)
1008
+
1009
+ let args: Record<string, unknown> = {}
1010
+ const raw = p.arguments
1011
+ if (raw !== undefined && raw !== null) {
1012
+ if (!isObj(raw)) return result({ output: "invalid arguments: must be an object", isError: true })
1013
+ args = raw
1014
+ }
1015
+
1016
+ const state = decodeState26(p.requestState)
1017
+ const hasState = state !== undefined && state.tool === name
1018
+ let confirmed = hasState && state!.confirmed === true
1019
+ let responses: Record<string, unknown> | undefined = isObj(p.inputResponses) ? p.inputResponses : undefined
1020
+
1021
+ if (cfg.beforeCall) {
1022
+ let d: McpDecision
1023
+ try {
1024
+ d = (await cfg.beforeCall(ctx, tool, args)) ?? {}
1025
+ } catch (e) {
1026
+ d = { kind: "deny", message: errMsg(e) }
1027
+ }
1028
+ const kind: McpDecisionKind = d.kind || "allow"
1029
+ ev.decision = kind
1030
+ if (kind === "deny") {
1031
+ if (d.result) return result(d.result)
1032
+ return result({ output: d.message || "denied", isError: true })
1033
+ }
1034
+ if (kind === "confirm" && !confirmed) {
1035
+ const ans = inputResponse26("confirm", responses?.confirm)
1036
+ if (hasState && ans) {
1037
+ if (!ans.ok) return result({ output: "declined", isError: true })
1038
+ if (ans.data?.confirm === false) return result({ output: "declined", isError: true })
1039
+ confirmed = true
1040
+ responses = undefined // the confirm round is consumed
1041
+ } else if (!canElicit26(meta)) {
1042
+ if (d.result) return result(d.result)
1043
+ return result({ output: "confirmation required: " + (d.message ?? ""), isError: true })
1044
+ } else {
1045
+ const msg = d.message || "Confirm this action?"
1046
+ return inputRequired({
1047
+ inputRequests: {
1048
+ confirm: {
1049
+ method: "elicitation/create",
1050
+ params: {
1051
+ mode: "form",
1052
+ message: msg,
1053
+ requestedSchema: {
1054
+ type: "object",
1055
+ properties: { confirm: { type: "boolean", title: "Confirm", description: msg } },
1056
+ },
1057
+ },
1058
+ },
1059
+ },
1060
+ requestState: encodeState26(name, false),
1061
+ })
1062
+ }
1063
+ }
1064
+ }
1065
+
1066
+ const tc: ToolContext = { signal: ctx.signal, mcp: ctx }
1067
+ if (hasState && responses && Object.keys(responses).length === 1) {
1068
+ const [k, v] = Object.entries(responses)[0]!
1069
+ const a = inputResponse26(k, v)
1070
+ if (a) tc.answer = a
1071
+ }
1072
+ let res: ToolResult
1073
+ let execErr: unknown
1074
+ try {
1075
+ res = await tool.execute(args, tc)
1076
+ } catch (e) {
1077
+ execErr = e
1078
+ res = { output: errMsg(e), isError: true }
1079
+ }
1080
+ const req = execErr === undefined ? pendingOf(res) : undefined
1081
+ if (req) {
1082
+ return inputRequired({ inputRequests: { [req.id]: inputRequest26(req) }, requestState: encodeState26(name, confirmed) })
1083
+ }
1084
+ return result(res, execErr)
1085
+ }
1086
+
1087
+ const handler = ((req: IncomingMessage, res: ServerResponse) => {
1088
+ const ac = new AbortController()
1089
+ res.on("close", () => ac.abort())
1090
+ const ctx: McpContext = { request: req, sessionId: "", signal: ac.signal }
1091
+ void (async () => {
1092
+ let pre: ReadResult | undefined
1093
+ if (req.method === "POST" && has2026 && isJsonPost(req)) {
1094
+ pre = await readBody(req)
1095
+ if (await tryPost2026(ctx, req, res, pre)) return
1096
+ }
1097
+ if (only2026 && req.method !== "POST") {
1098
+ req.resume()
1099
+ return httpError(res, 405, "Method Not Allowed", { Allow: "POST" })
1100
+ }
1101
+ if (cfg.authenticate) {
1102
+ try {
1103
+ ctx.identity = await cfg.authenticate(req)
1104
+ } catch (err) {
1105
+ if (!pre) req.resume()
1106
+ return writeAuthFailure(cfg, req, res, err)
1107
+ }
1108
+ }
1109
+ switch (req.method) {
1110
+ case "POST":
1111
+ return handlePost(ctx, req, res, pre)
1112
+ case "GET":
1113
+ return handleGet(ctx, req, res)
1114
+ case "DELETE":
1115
+ return handleDelete(ctx, req, res)
1116
+ default:
1117
+ return httpError(res, 405, "Method Not Allowed", { Allow: "GET, POST, DELETE" })
1118
+ }
1119
+ })().catch((e) => {
1120
+ if (!res.headersSent) httpError(res, 500, "Internal Server Error: " + errMsg(e))
1121
+ else res.end()
1122
+ })
1123
+ }) as McpHandler
1124
+
1125
+ handler.notifyToolsChanged = () => {
1126
+ const line = `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })}\n\n`
1127
+ for (const s of streams) {
1128
+ try {
1129
+ s.write(line)
1130
+ } catch {
1131
+ // a dead stream is dropped by its close handler
1132
+ }
1133
+ }
1134
+ }
1135
+ return handler
1136
+ }
1137
+
1138
+ // ---- §7C.2 helpers ----
1139
+
1140
+ interface Err26 {
1141
+ code: number
1142
+ message: string
1143
+ data?: unknown
1144
+ }
1145
+ interface Out26 {
1146
+ status?: number
1147
+ result?: Record<string, unknown>
1148
+ error?: Err26
1149
+ }
1150
+
1151
+ function write26(res: ServerResponse, status: number, id: unknown, result: Record<string, unknown> | undefined, error: Err26 | undefined) {
1152
+ const msg: Record<string, unknown> = { jsonrpc: "2.0", id: id ?? null }
1153
+ if (error) msg.error = error
1154
+ else msg.result = result
1155
+ res.writeHead(status, { "Content-Type": "application/json" })
1156
+ res.end(JSON.stringify(msg) + "\n")
1157
+ }
1158
+
1159
+ const skillFrontmatter = (s: McpSkill): Record<string, unknown> => s.frontmatter ?? { name: s.name }
1160
+
1161
+ function skillEntry(s: McpSkill): Record<string, unknown> {
1162
+ const files = [...(s.files ?? [])].sort((a, b) => compareCodePoints(a.path, b.path))
1163
+ return {
1164
+ uri: `skill://${s.name}/SKILL.md`,
1165
+ frontmatter: skillFrontmatter(s),
1166
+ resources: files.map((f) => ({
1167
+ uri: `skill://${s.name}/${f.path}`,
1168
+ digest: "sha256:" + createHash("sha256").update(f.text, "utf8").digest("hex"),
1169
+ size: Buffer.byteLength(f.text, "utf8"),
1170
+ })),
1171
+ }
1172
+ }
1173
+
1174
+ /** Percent-decodes once, like Go's url.PathUnescape; undefined on a malformed escape. */
1175
+ function pathUnescape(s: string): string | undefined {
1176
+ if (!s.includes("%")) return s
1177
+ const bytes: number[] = []
1178
+ for (let i = 0; i < s.length; ) {
1179
+ if (s[i] === "%") {
1180
+ const hex = s.slice(i + 1, i + 3)
1181
+ if (!/^[0-9a-fA-F]{2}$/.test(hex)) return undefined
1182
+ bytes.push(parseInt(hex, 16))
1183
+ i += 3
1184
+ } else {
1185
+ const cp = s.codePointAt(i)!
1186
+ const ch = String.fromCodePoint(cp)
1187
+ bytes.push(...Buffer.from(ch, "utf8"))
1188
+ i += ch.length
1189
+ }
1190
+ }
1191
+ return Buffer.from(bytes).toString("utf8")
1192
+ }
1193
+
1194
+ /** Validates skill://<name>/<path> (SPEC §2.7 containment). */
1195
+ function parseSkillUri(uri: string): { name: string; rel: string } | { error: string } {
1196
+ if (!uri.startsWith("skill://")) return { error: "not a skill:// URI" }
1197
+ const dec = pathUnescape(uri.slice("skill://".length))
1198
+ if (dec === undefined) return { error: "bad percent-encoding" }
1199
+ if (/[%\\\0]/.test(dec)) return { error: "skill URI has encoded, backslash or NUL characters" }
1200
+ const segs = dec.split("/")
1201
+ if (segs.length < 2) return { error: "skill URI needs skill://<name>/<path>" }
1202
+ for (const s of segs) {
1203
+ if (s === "" || s === "." || s === ".." || s.includes(":")) {
1204
+ return { error: "skill URI path escapes the skill (empty, '.', '..' or ':' segment)" }
1205
+ }
1206
+ }
1207
+ return { name: segs[0]!, rel: segs.slice(1).join("/") }
1208
+ }
1209
+
1210
+ /** requestState = base64url-nopad(compact JSON {"tool":…[,"confirmed":true]}); not integrity-protected. */
1211
+ function encodeState26(tool: string, confirmed: boolean): string {
1212
+ const o: Record<string, unknown> = { tool }
1213
+ if (confirmed) o.confirmed = true
1214
+ return Buffer.from(JSON.stringify(o), "utf8").toString("base64url")
1215
+ }
1216
+
1217
+ function decodeState26(v: unknown): { tool: string; confirmed?: boolean } | undefined {
1218
+ if (typeof v !== "string" || !/^[A-Za-z0-9_-]+$/.test(v) || v.length % 4 === 1) return undefined
1219
+ try {
1220
+ const o = JSON.parse(Buffer.from(v, "base64url").toString("utf8"))
1221
+ if (!isObj(o)) return undefined
1222
+ if (o.tool !== undefined && o.tool !== null && typeof o.tool !== "string") return undefined
1223
+ if (o.confirmed !== undefined && o.confirmed !== null && typeof o.confirmed !== "boolean") return undefined
1224
+ return { tool: o.tool ?? "", confirmed: o.confirmed === true }
1225
+ } catch {
1226
+ return undefined
1227
+ }
1228
+ }
1229
+
1230
+ /** Maps one MRTR input response onto a §10 Answer. */
1231
+ function inputResponse26(key: string, v: unknown): Answer | undefined {
1232
+ if (!isObj(v)) return undefined
1233
+ const ok = v.action === "accept"
1234
+ const a: Answer = { id: key, ok }
1235
+ if (isObj(v.content)) a.data = v.content
1236
+ if (!ok) a.reason = v.action === "decline" ? "declined" : "cancelled"
1237
+ return a
1238
+ }
1239
+
1240
+ const canElicit26 = (meta: Record<string, unknown> | undefined): boolean => {
1241
+ const caps = meta?.[META_CLIENT_CAPS]
1242
+ return isObj(caps) && "elicitation" in caps
1243
+ }
1244
+
1245
+ /** A §10 Request as an MRTR input request: authorization+url ⇒ URL mode, else a form. */
1246
+ function inputRequest26(r: Request): Record<string, unknown> {
1247
+ if (r.kind === "authorization" && r.url) {
1248
+ return { method: "elicitation/create", params: { mode: "url", message: r.prompt, url: r.url, elicitationId: r.id } }
1249
+ }
1250
+ const schema = isObj(r.data?.schema) ? r.data!.schema : { type: "object", properties: {} }
1251
+ return { method: "elicitation/create", params: { mode: "form", message: r.prompt, requestedSchema: schema } }
1252
+ }