toolnexus 0.24.0 → 0.26.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 (63) hide show
  1. package/README.md +151 -1
  2. package/dist/acp.d.ts +37 -6
  3. package/dist/acp.js +239 -52
  4. package/dist/classifier.d.ts +189 -31
  5. package/dist/classifier.js +589 -30
  6. package/dist/client.d.ts +38 -8
  7. package/dist/client.js +102 -23
  8. package/dist/index.d.ts +2 -0
  9. package/dist/index.js +2 -0
  10. package/dist/judge.d.ts +6 -3
  11. package/dist/judge.js +13 -1
  12. package/dist/mcp.d.ts +32 -7
  13. package/dist/mcp.js +125 -36
  14. package/dist/mcp2026/auth.d.ts +137 -0
  15. package/dist/mcp2026/auth.js +446 -0
  16. package/dist/mcp2026/client.d.ts +48 -0
  17. package/dist/mcp2026/client.js +160 -0
  18. package/dist/mcp2026/protocol.d.ts +54 -0
  19. package/dist/mcp2026/protocol.js +113 -0
  20. package/dist/mcp2026/skills.d.ts +5 -0
  21. package/dist/mcp2026/skills.js +25 -0
  22. package/dist/mcp2026/source.d.ts +86 -0
  23. package/dist/mcp2026/source.js +372 -0
  24. package/dist/mcp2026/stdio.d.ts +24 -0
  25. package/dist/mcp2026/stdio.js +99 -0
  26. package/dist/mcp2026/toolsearch.d.ts +5 -0
  27. package/dist/mcp2026/toolsearch.js +58 -0
  28. package/dist/mcphandler.d.ts +169 -0
  29. package/dist/mcphandler.js +1145 -0
  30. package/dist/mcpheader.d.ts +5 -0
  31. package/dist/mcpheader.js +18 -0
  32. package/dist/mcpserve.d.ts +24 -15
  33. package/dist/mcpserve.js +105 -2
  34. package/dist/mcpserverauth.d.ts +48 -0
  35. package/dist/mcpserverauth.js +203 -0
  36. package/dist/serve.js +7 -0
  37. package/dist/skill.d.ts +21 -0
  38. package/dist/skill.js +44 -0
  39. package/dist/toolkit.d.ts +20 -1
  40. package/dist/toolkit.js +25 -3
  41. package/dist/types.d.ts +22 -0
  42. package/package.json +4 -3
  43. package/src/acp.ts +258 -55
  44. package/src/classifier.ts +677 -46
  45. package/src/client.ts +130 -27
  46. package/src/index.ts +2 -0
  47. package/src/judge.ts +19 -4
  48. package/src/mcp.ts +162 -43
  49. package/src/mcp2026/auth.ts +473 -0
  50. package/src/mcp2026/client.ts +161 -0
  51. package/src/mcp2026/protocol.ts +135 -0
  52. package/src/mcp2026/skills.ts +22 -0
  53. package/src/mcp2026/source.ts +407 -0
  54. package/src/mcp2026/stdio.ts +102 -0
  55. package/src/mcp2026/toolsearch.ts +56 -0
  56. package/src/mcphandler.ts +1252 -0
  57. package/src/mcpheader.ts +19 -0
  58. package/src/mcpserve.ts +115 -2
  59. package/src/mcpserverauth.ts +216 -0
  60. package/src/serve.ts +7 -0
  61. package/src/skill.ts +50 -0
  62. package/src/toolkit.ts +31 -4
  63. package/src/types.ts +23 -0
@@ -0,0 +1,135 @@
1
+ /**
2
+ * MCP 2026-07-28 wire constants + a tiny node:http(s) layer (SPEC §2.1, §2.3). No MCP SDK.
3
+ * node:http instead of fetch so a per-request trust store is possible (dev authorization servers).
4
+ */
5
+ import http from "node:http"
6
+ import https from "node:https"
7
+ import tls from "node:tls"
8
+
9
+ export const PROTOCOL_VERSION = "2026-07-28"
10
+ export const META_VERSION = "io.modelcontextprotocol/protocolVersion"
11
+ export const META_CLIENT_CAPS = "io.modelcontextprotocol/clientCapabilities"
12
+ export const META_CLIENT_INFO = "io.modelcontextprotocol/clientInfo"
13
+ export const META_SERVER_INFO = "io.modelcontextprotocol/serverInfo"
14
+ export const SKILLS_EXT = "io.modelcontextprotocol/skills"
15
+
16
+ export const CODE_METHOD_NOT_FOUND = -32601
17
+ export const CODE_INVALID_PARAMS = -32602
18
+ export const CODE_HEADER_MISMATCH = -32020
19
+ export const CODE_UNSUPPORTED_PROTOCOL = -32022
20
+
21
+ /** A JSON object. */
22
+ export type Obj = Record<string, any>
23
+
24
+ /** A JSON-RPC error returned by the server. `httpStatus` is set when it came over HTTP. */
25
+ export class RpcError extends Error {
26
+ code: number
27
+ data?: unknown
28
+ httpStatus?: number
29
+ constructor(code: number, message: string, data?: unknown) {
30
+ super(message)
31
+ this.code = code
32
+ if (data !== undefined) this.data = data
33
+ }
34
+ }
35
+
36
+ /** A non-2xx HTTP reply that carried no JSON-RPC message. */
37
+ export class HttpStatusError extends Error {
38
+ status: number
39
+ constructor(status: number, message?: string) {
40
+ super(message ?? `HTTP ${status}`)
41
+ this.status = status
42
+ }
43
+ }
44
+
45
+ /** A 2xx reply that is not a JSON-RPC response for the request (§2.2a: falls back on discover). */
46
+ export class NotJsonRpcError extends Error {}
47
+
48
+ export const DEFAULT_TIMEOUT_MS = 30_000
49
+ const MAX_RESPONSE_BYTES = 16 << 20
50
+
51
+ export interface HttpRequest {
52
+ method: string
53
+ url: string
54
+ headers?: Record<string, string>
55
+ body?: string
56
+ timeoutMs?: number
57
+ signal?: AbortSignal
58
+ }
59
+ export interface HttpResponse {
60
+ status: number
61
+ headers: Record<string, string> // lower-case names
62
+ body: string
63
+ }
64
+ /** The seam for every outbound HTTP call. */
65
+ export type HttpFn = (r: HttpRequest) => Promise<HttpResponse>
66
+
67
+ // Test/dev only: extra CA certificates trusted by defaultHttp (a dev authorization server's
68
+ // self-signed certificate). Not exported from the package index.
69
+ const extraCAs: string[] = []
70
+ export function trustDevCert(pem: string): void {
71
+ extraCAs.push(pem)
72
+ }
73
+
74
+ /** node:http(s) request; reads the whole body (capped at 16 MiB). Never follows redirects. */
75
+ export const defaultHttp: HttpFn = (r) =>
76
+ new Promise((resolve, reject) => {
77
+ const u = new URL(r.url)
78
+ const opts: https.RequestOptions = { method: r.method, headers: r.headers ?? {}, agent: false }
79
+ if (u.protocol === "https:" && extraCAs.length) opts.ca = [...tls.rootCertificates, ...extraCAs]
80
+ if (r.signal?.aborted) return reject(r.signal.reason ?? new Error("aborted"))
81
+ const req = (u.protocol === "https:" ? https : http).request(u, opts, (res) => {
82
+ const chunks: Buffer[] = []
83
+ let n = 0
84
+ res.on("data", (c: Buffer) => {
85
+ n += c.length
86
+ if (n > MAX_RESPONSE_BYTES) {
87
+ req.destroy(new Error("response too large"))
88
+ return
89
+ }
90
+ chunks.push(c)
91
+ })
92
+ res.on("end", () => {
93
+ const headers: Record<string, string> = {}
94
+ for (const [k, v] of Object.entries(res.headers)) if (v !== undefined) headers[k] = Array.isArray(v) ? v.join(", ") : v
95
+ resolve({ status: res.statusCode ?? 0, headers, body: Buffer.concat(chunks).toString("utf8") })
96
+ })
97
+ res.on("error", reject)
98
+ })
99
+ const onAbort = () => req.destroy(r.signal?.reason instanceof Error ? r.signal.reason : new Error("aborted"))
100
+ r.signal?.addEventListener("abort", onAbort, { once: true })
101
+ req.on("close", () => r.signal?.removeEventListener("abort", onAbort))
102
+ req.setTimeout(r.timeoutMs ?? DEFAULT_TIMEOUT_MS, () => req.destroy(new Error(`request to ${u.host} timed out`)))
103
+ req.on("error", reject)
104
+ req.end(r.body)
105
+ })
106
+
107
+ export async function getJSON(httpFn: HttpFn, url: string): Promise<Obj> {
108
+ const res = await httpFn({ method: "GET", url, headers: { Accept: "application/json" } })
109
+ if (res.status !== 200) throw new Error(`GET ${url}: HTTP ${res.status}`)
110
+ const v = JSON.parse(res.body)
111
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error(`GET ${url}: not a JSON object`)
112
+ return v
113
+ }
114
+
115
+ /** A small async mutex (one login / one refresh at a time). */
116
+ export class Mutex {
117
+ private tail: Promise<void> = Promise.resolve()
118
+ async lock<T>(f: () => Promise<T>): Promise<T> {
119
+ let release!: () => void
120
+ const prev = this.tail
121
+ this.tail = new Promise((r) => (release = r))
122
+ await prev
123
+ try {
124
+ return await f()
125
+ } finally {
126
+ release()
127
+ }
128
+ }
129
+ }
130
+
131
+ /** Plain value if printable ASCII without edge whitespace, else the =?base64?…?= sentinel (§2.3). */
132
+ export function encodeHeader(v: string): string {
133
+ const safe = v === v.trim() && !(v.startsWith("=?base64?") && v.endsWith("?=")) && /^[\x20-\x7e]*$/.test(v)
134
+ return safe ? v : "=?base64?" + Buffer.from(v, "utf8").toString("base64") + "?="
135
+ }
@@ -0,0 +1,22 @@
1
+ // Skill URI safety (python-sdk #3685): a skill:// URI is percent-decoded ONCE, then rejected if it
2
+ // still contains '%' (double encoding), a backslash, NUL, an absolute/empty path, or a "."/".."
3
+ // segment. Both the server (before lookup) and the client (before writing files) use it.
4
+
5
+ /** Splits skill://<name>/<path> into a validated name and slash-separated relative path. Throws on anything unsafe. */
6
+ export function parseSkillURI(uri: string): { name: string; rel: string } {
7
+ if (typeof uri !== "string" || !uri.startsWith("skill://")) throw new Error("not a skill:// URI")
8
+ let dec: string
9
+ try {
10
+ dec = decodeURIComponent(uri.slice("skill://".length))
11
+ } catch {
12
+ throw new Error("bad percent-encoding")
13
+ }
14
+ if (/[%\\\x00]/.test(dec)) throw new Error("skill URI has encoded, backslash or NUL characters")
15
+ const segs = dec.split("/")
16
+ if (segs.length < 2) throw new Error("skill URI needs skill://<name>/<path>")
17
+ for (const s of segs) {
18
+ if (s === "" || s === "." || s === ".." || s.includes(":"))
19
+ throw new Error("skill URI path escapes the skill (empty, '.', '..' or ':' segment)")
20
+ }
21
+ return { name: segs[0], rel: segs.slice(1).join("/") }
22
+ }
@@ -0,0 +1,407 @@
1
+ /**
2
+ * The 2026-07-28 direct path behind loadMcp (SPEC §2.2–§2.8, §2A): discover first, then tools,
3
+ * skills and tool search; MRTR and OAuth pauses surface through §10 (waitFor inline, or a
4
+ * `{status:"pending"}` suspension resumed via `ctx.answer`). `probe` reports `fallback` when the
5
+ * server is pre-2026 so loadMcp takes the frozen SDK path (§2.F).
6
+ */
7
+ import { randomBytes } from "node:crypto"
8
+ import { Mcp2026Client } from "./client.js"
9
+ import { HttpStatusError, NotJsonRpcError, PROTOCOL_VERSION, RpcError, SKILLS_EXT, type Obj } from "./protocol.js"
10
+ import { LoopbackAuthorizer, MemoryTokenStore, OAuth, PauseAuthorizer, PendingError, type TokenStore } from "./auth.js"
11
+ import { spawnStdio, StdioClosedError } from "./stdio.js"
12
+ import { rank } from "./toolsearch.js"
13
+ import type { Answer, JSONSchema, Request, Tool, ToolContext, ToolResult } from "../types.js"
14
+ import { sanitize } from "../types.js"
15
+ import type { SkillDef } from "../skill.js"
16
+ import { answerToElicitResult, elicitationToRequest, mapCallToolResult, mcpHints } from "../mcp.js"
17
+
18
+ export type WaitFor = (request: Request) => Promise<Answer>
19
+
20
+ /** Per-server `auth` (§2A.1; keys camelCase as in mcp.json). */
21
+ export interface AuthConfig {
22
+ clientId?: string
23
+ clientSecret?: string
24
+ tokenAuthMethod?: "client_secret_basic" | "client_secret_post"
25
+ clientIds?: Record<string, string>
26
+ clientMetadataUrl?: string
27
+ redirectUri?: string
28
+ mode?: "auto" | "pause"
29
+ omitRefreshResource?: boolean
30
+ allowHttp?: boolean
31
+ }
32
+
33
+ export interface Load2026Options {
34
+ waitFor?: WaitFor
35
+ tokenStore?: TokenStore
36
+ openUrl?: (url: string) => unknown | Promise<unknown>
37
+ signal?: AbortSignal
38
+ /** Final exposed names to drop (Toolkit `disableTools`); applied before tool search too. */
39
+ disableTools?: string[]
40
+ }
41
+
42
+ /** The OAuth engine for one remote server (§2A.1). */
43
+ export function newOAuth(auth: AuthConfig | undefined, o: Load2026Options, store: TokenStore): OAuth {
44
+ const a = auth ?? {}
45
+ const mode = a.mode ?? "auto"
46
+ let authorizer
47
+ if (mode === "pause") {
48
+ if (!a.redirectUri) throw new Error("auth.redirectUri is required for pause mode")
49
+ authorizer = new PauseAuthorizer({ redirect: a.redirectUri })
50
+ } else {
51
+ authorizer = new LoopbackAuthorizer(o.openUrl ? { open: o.openUrl } : {})
52
+ }
53
+ return new OAuth({
54
+ authorizer, store,
55
+ clientId: a.clientId, clientSecret: a.clientSecret, tokenAuthMethod: a.tokenAuthMethod,
56
+ clientIds: a.clientIds, clientMetadataUrl: a.clientMetadataUrl,
57
+ omitRefreshResource: a.omitRefreshResource, allowHttp: a.allowHttp,
58
+ })
59
+ }
60
+
61
+ export function defaultTokenStore(o: Load2026Options): TokenStore {
62
+ return o.tokenStore ?? new MemoryTokenStore()
63
+ }
64
+
65
+ function deniedError(a: Answer | undefined): Error {
66
+ return new Error(`authorization denied: ${a?.reason ?? "cancelled"}`)
67
+ }
68
+
69
+ function callbackUrlOf(a: Answer): string {
70
+ const u = a.data?.callbackUrl
71
+ if (typeof u !== "string" || !u) throw new Error("authorization answer carries no data.callbackUrl")
72
+ return u
73
+ }
74
+
75
+ /** Runs f; an OAuth pause is answered inline through waitFor, else it is a load failure. */
76
+ async function authed(oauth: OAuth | undefined, waitFor: WaitFor | undefined, f: () => Promise<Obj>): Promise<Obj> {
77
+ try {
78
+ return await f()
79
+ } catch (e) {
80
+ if (!(e instanceof PendingError) || !oauth) throw e
81
+ if (!waitFor) throw new Error("authorization pending")
82
+ const a = await waitFor({ ...e.pending })
83
+ if (!a?.ok) throw deniedError(a)
84
+ // resume() stores the token (and re-sends the paused read); f() then runs again, authorized.
85
+ await oauth.resume(e.pending.id, callbackUrlOf(a))
86
+ return f()
87
+ }
88
+ }
89
+
90
+ /** What the probe decided. `fallback` ⇒ take the frozen SDK path; anything thrown ⇒ status failed. */
91
+ export type Probe = { kind: "modern"; server: ModernServer } | { kind: "fallback" }
92
+
93
+ /** §2.2a: which discover failures mean "old server". */
94
+ function isFallbackError(e: unknown): boolean {
95
+ if (e instanceof RpcError) return true
96
+ if (e instanceof HttpStatusError) return e.status === 400 || e.status === 404 || e.status === 405 || e.status >= 500
97
+ if (e instanceof NotJsonRpcError) return true
98
+ return false
99
+ }
100
+
101
+ function supports2026(r: Obj): boolean {
102
+ return Array.isArray(r.supportedVersions) && r.supportedVersions.includes(PROTOCOL_VERSION)
103
+ }
104
+
105
+ export interface ProbeTarget {
106
+ name: string
107
+ timeout: number
108
+ tools?: Record<string, boolean>
109
+ toolSearch?: boolean
110
+ description?: string
111
+ remote?: { url: string; headers?: Record<string, string>; oauth: OAuth }
112
+ local?: { argv: string[]; env: NodeJS.ProcessEnv; cwd?: string }
113
+ }
114
+
115
+ /** Discover first (§2.2). Resolves `modern` (tools listed) or `fallback`; throws on a load failure. */
116
+ export async function probe(t: ProbeTarget, o: Load2026Options): Promise<Probe> {
117
+ let client: Mcp2026Client
118
+ if (t.remote) {
119
+ client = new Mcp2026Client(t.remote.url, { headers: t.remote.headers, auth: t.remote.oauth, timeoutMs: t.timeout })
120
+ } else {
121
+ const tr = await spawnStdio(t.local!.argv, t.local!.env, t.local!.cwd)
122
+ client = new Mcp2026Client("", { transport: tr, timeoutMs: t.timeout })
123
+ }
124
+ const oauth = t.remote?.oauth
125
+ let disc: Obj
126
+ try {
127
+ // stdio: an old server may ignore a pre-initialize request ⇒ min(timeout, 5 s) then fall back.
128
+ const timeoutMs = t.local ? Math.min(t.timeout, 5000) : t.timeout
129
+ disc = await authed(oauth, o.waitFor, () => client.call("server/discover", {}, { timeoutMs, signal: o.signal }))
130
+ } catch (e) {
131
+ const stdioTimeout = !!t.local && e instanceof Error && e.message === "stdio request timed out"
132
+ await client.close().catch(() => {})
133
+ if (o.signal?.aborted) throw e
134
+ if (e instanceof StdioClosedError) throw e
135
+ if (isFallbackError(e) || stdioTimeout) return { kind: "fallback" }
136
+ throw e
137
+ }
138
+ if (!supports2026(disc)) {
139
+ await client.close().catch(() => {})
140
+ return { kind: "fallback" }
141
+ }
142
+ try {
143
+ const server = new ModernServer(t, client, disc, o)
144
+ await server.load()
145
+ return { kind: "modern", server }
146
+ } catch (e) {
147
+ await client.close().catch(() => {})
148
+ throw e
149
+ }
150
+ }
151
+
152
+ /** §2 Gap 7 filter over raw defs (same semantics as the frozen path). */
153
+ function filterDefs(server: string, defs: Obj[], filter?: Record<string, boolean>): Obj[] {
154
+ if (!filter) return defs
155
+ const keys = Object.keys(filter)
156
+ if (keys.length === 0) return defs
157
+ const hasTrue = keys.some((k) => filter[k] === true)
158
+ const present = new Set(defs.map((d) => d.name))
159
+ for (const k of keys) if (!present.has(k)) console.warn(`[toolnexus] server "${server}" tools filter name "${k}" matched no tool`)
160
+ return defs.filter((d) => (hasTrue ? filter[d.name] === true : filter[d.name] !== false))
161
+ }
162
+
163
+ function parseFrontmatter(md: string): { data: Record<string, string>; body: string } {
164
+ const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/)
165
+ if (!m) return { data: {}, body: md }
166
+ const data: Record<string, string> = {}
167
+ for (const line of m[1].split(/\r?\n/)) {
168
+ if (/^\s/.test(line)) continue
169
+ const i = line.indexOf(":")
170
+ if (i <= 0) continue
171
+ let v = line.slice(i + 1).trim()
172
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1)
173
+ data[line.slice(0, i).trim()] = v
174
+ }
175
+ return { data, body: m[2] }
176
+ }
177
+
178
+ /** A suspended tools/call: where to continue once the Answer arrives. */
179
+ interface Cont {
180
+ name: string
181
+ args: Obj
182
+ round: number
183
+ state?: unknown
184
+ responses?: Obj
185
+ reqs: Obj
186
+ keys: string[]
187
+ idx: number
188
+ asked?: Request
189
+ authId?: string
190
+ }
191
+
192
+ const MAX_ROUNDS = 8
193
+
194
+ export class ModernServer {
195
+ readonly client: Mcp2026Client
196
+ readonly discover: Obj
197
+ defs: Obj[] = []
198
+ skills: SkillDef[] = []
199
+ private all: Obj[] = []
200
+ private cacheUntil = Infinity
201
+ private pend = new Map<string, Cont>()
202
+
203
+ constructor(private t: ProbeTarget, client: Mcp2026Client, disc: Obj, private o: Load2026Options) {
204
+ this.client = client
205
+ this.discover = disc
206
+ }
207
+
208
+ private get oauth(): OAuth | undefined {
209
+ return this.t.remote?.oauth
210
+ }
211
+
212
+ private req<T extends Obj>(f: () => Promise<T>): Promise<Obj> {
213
+ return authed(this.oauth, this.o.waitFor, f)
214
+ }
215
+
216
+ async load(): Promise<void> {
217
+ await this.refreshTools()
218
+ if (this.discover.capabilities?.extensions?.[SKILLS_EXT] !== undefined) await this.loadSkills()
219
+ }
220
+
221
+ private async refreshTools(): Promise<void> {
222
+ const r = await this.req(() => this.client.listTools({ signal: this.o.signal }))
223
+ this.all = r.tools
224
+ const off = new Set(this.o.disableTools ?? [])
225
+ const prefix = sanitize(this.t.name) + "_"
226
+ this.defs = filterDefs(this.t.name, r.tools, this.t.tools).filter((d) => !off.has(prefix + sanitize(d.name)))
227
+ this.cacheUntil = typeof r.ttlMs === "number" ? Date.now() + r.ttlMs : Infinity
228
+ }
229
+
230
+ private async loadSkills(): Promise<void> {
231
+ const r = await this.req(() => this.client.call("skills/list", {}, { signal: this.o.signal }))
232
+ for (const s of Array.isArray(r.skills) ? r.skills : []) {
233
+ const uri = String(s?.uri ?? "")
234
+ try {
235
+ const f = await this.req(() => this.client.fetchSkill(uri, { signal: this.o.signal }))
236
+ const { data, body } = parseFrontmatter(f.markdown)
237
+ const name = data.name || f.name
238
+ this.skills.push({ name, description: data.description ?? "", content: body, resources: f.resources, base: `skill://${f.name}/` })
239
+ } catch (e) {
240
+ console.warn(`[toolnexus] MCP server "${this.t.name}" skill ${JSON.stringify(uri)} skipped: ${e instanceof Error ? e.message : String(e)}`)
241
+ }
242
+ }
243
+ }
244
+
245
+ /** The toolkit tools for this server: every (filtered) server tool, or the two tool-search tools. */
246
+ tools(): Tool[] {
247
+ const server = this.t.name
248
+ if (!this.t.toolSearch) return this.defs.map((d) => this.convert(d))
249
+ const about = this.t.description || server
250
+ const search: Tool = {
251
+ name: sanitize(server) + "_search_tools",
252
+ description: `Search the tools of ${about} by keywords. Returns name, description and inputSchema of the best matches; run one with call_tool.`,
253
+ inputSchema: { type: "object", properties: { query: { type: "string" }, limit: { type: "integer" } }, required: ["query"] },
254
+ source: "mcp",
255
+ execute: async (args, ctx) => {
256
+ try {
257
+ if (Date.now() >= this.cacheUntil) await this.refreshTools()
258
+ const limit = typeof args?.limit === "number" && args.limit > 0 ? Math.trunc(args.limit) : 5
259
+ const hits = rank(this.defs, String(args?.query ?? ""), limit).map((d) => ({
260
+ name: d.name, description: d.description, inputSchema: d.inputSchema, server: this.t.description ?? "",
261
+ }))
262
+ return { output: JSON.stringify(hits), isError: false, metadata: { server } }
263
+ } catch (e) {
264
+ return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } }
265
+ }
266
+ },
267
+ }
268
+ const call: Tool = {
269
+ name: sanitize(server) + "_call_tool",
270
+ description: "Run a tool found with search_tools.",
271
+ inputSchema: { type: "object", properties: { name: { type: "string" }, arguments: { type: "object" } }, required: ["name"] },
272
+ source: "mcp",
273
+ execute: async (args, ctx) => {
274
+ const name = String(args?.name ?? "")
275
+ const a = args?.arguments && typeof args.arguments === "object" && !Array.isArray(args.arguments) ? (args.arguments as Obj) : {}
276
+ if (!(ctx?.answer && this.pend.has(ctx.answer.id)) && !this.defs.some((d) => d.name === name)) {
277
+ return { output: `unknown tool "${name}" on ${server}`, isError: true, metadata: { server } }
278
+ }
279
+ return this.run(name, a, ctx)
280
+ },
281
+ }
282
+ return [search, call]
283
+ }
284
+
285
+ private convert(def: Obj): Tool {
286
+ const inputSchema: JSONSchema = {
287
+ ...(def.inputSchema as object),
288
+ type: "object",
289
+ properties: (def.inputSchema?.properties ?? {}) as Record<string, unknown>,
290
+ additionalProperties: false,
291
+ }
292
+ const hints = mcpHints(def.annotations)
293
+ return {
294
+ name: sanitize(this.t.name) + "_" + sanitize(def.name),
295
+ description: def.description ?? "",
296
+ inputSchema,
297
+ source: "mcp",
298
+ ...(hints ? { hints } : {}),
299
+ execute: (args, ctx) => this.run(def.name, args ?? {}, ctx),
300
+ }
301
+ }
302
+
303
+ /** tools/call with MRTR rounds (§2.5) and OAuth pauses (§2A.1 10); never re-sends after a failure (§2.6). */
304
+ async run(name: string, args: Obj, ctx?: ToolContext): Promise<ToolResult> {
305
+ const server = this.t.name
306
+ try {
307
+ const answer = ctx?.answer
308
+ const cont = answer ? this.pend.get(answer.id) : undefined
309
+ if (cont && answer) {
310
+ this.pend.delete(answer.id)
311
+ return await this.drive(cont, answer, ctx)
312
+ }
313
+ return await this.drive({ name, args, round: 0, reqs: {}, keys: [], idx: 0 }, undefined, ctx)
314
+ } catch (e) {
315
+ return { output: e instanceof Error ? e.message : String(e), isError: true, metadata: { server } }
316
+ }
317
+ }
318
+
319
+ private suspend(cont: Cont, req: Request): ToolResult {
320
+ this.pend.set(req.id, cont)
321
+ const output = req.url ? `${req.prompt}: ${req.url}` : req.prompt
322
+ return { output, isError: true, metadata: { pending: req, server: this.t.name } }
323
+ }
324
+
325
+ private async drive(cont: Cont, answer: Answer | undefined, ctx?: ToolContext): Promise<ToolResult> {
326
+ const waitFor = this.o.waitFor
327
+ let res: Obj | undefined
328
+ if (answer) {
329
+ if (cont.authId) {
330
+ if (!answer.ok) throw deniedError(answer)
331
+ const id = cont.authId
332
+ cont.authId = undefined
333
+ res = await this.oauth!.resume(id, callbackUrlOf(answer))
334
+ } else {
335
+ cont.responses![cont.keys[cont.idx]] = answerToElicitResult(answer)
336
+ cont.idx++
337
+ }
338
+ }
339
+ for (;;) {
340
+ if (!res) {
341
+ while (cont.idx < cont.keys.length) {
342
+ const key = cont.keys[cont.idx]
343
+ const req = this.toRequest(key, cont.reqs[key])
344
+ if (!waitFor) return this.suspend(cont, req)
345
+ cont.responses![key] = answerToElicitResult(await waitFor(req))
346
+ cont.idx++
347
+ }
348
+ if (cont.round >= MAX_ROUNDS) throw new Error(`tools/call ${cont.name}: too many input_required rounds`)
349
+ const p: Obj = { name: cont.name, arguments: cont.args }
350
+ if (cont.responses) p.inputResponses = cont.responses
351
+ if (cont.state !== undefined && cont.state !== null) p.requestState = cont.state
352
+ try {
353
+ res = await this.client.call("tools/call", p, { timeoutMs: ctx?.timeout, signal: ctx?.signal })
354
+ } catch (e) {
355
+ if (!(e instanceof PendingError) || !this.oauth) throw e
356
+ // Rejected before dispatch (401/403): this round has NOT run. Resume sends it once.
357
+ if (!waitFor) {
358
+ cont.authId = e.pending.id
359
+ return this.suspend(cont, { ...e.pending })
360
+ }
361
+ const a = await waitFor({ ...e.pending })
362
+ if (!a?.ok) throw deniedError(a)
363
+ res = await this.oauth.resume(e.pending.id, callbackUrlOf(a))
364
+ }
365
+ }
366
+ if (res.resultType !== "input_required") return mapCallToolResult(res, this.t.name)
367
+ const reqs: Obj = res.inputRequests && typeof res.inputRequests === "object" && !Array.isArray(res.inputRequests) ? res.inputRequests : {}
368
+ for (const [k, v] of Object.entries(reqs)) {
369
+ if (!v || typeof v !== "object" || Array.isArray(v)) throw new Error(`tools/call ${cont.name}: malformed input request "${k}"`)
370
+ }
371
+ cont.round++
372
+ cont.state = res.requestState
373
+ cont.responses = {}
374
+ cont.reqs = reqs
375
+ cont.keys = Object.keys(reqs)
376
+ cont.idx = 0
377
+ res = undefined
378
+ }
379
+ }
380
+
381
+ private toRequest(key: string, ir: Obj): Request {
382
+ if (ir.method !== "elicitation/create") throw new Error(`unsupported input request method ${JSON.stringify(ir.method)} ("${key}")`)
383
+ const req = elicitationToRequest(ir.params ?? {})
384
+ req.id = "mrtr-" + randomBytes(9).toString("base64url")
385
+ return req
386
+ }
387
+
388
+ /** resources/list for list_mcp_resources (§4C). */
389
+ async listResources(signal?: AbortSignal): Promise<Obj[]> {
390
+ const r = await this.req(() => this.client.call("resources/list", {}, { signal }))
391
+ return Array.isArray(r.resources) ? r.resources : []
392
+ }
393
+
394
+ async readResource(uri: string, signal?: AbortSignal): Promise<Obj[]> {
395
+ const r = await this.req(() => this.client.call("resources/read", { uri }, { signal }))
396
+ return Array.isArray(r.contents) ? r.contents : []
397
+ }
398
+
399
+ /** Raw (unfiltered) tool defs, for listMcpTools. */
400
+ rawDefs(): Obj[] {
401
+ return this.all
402
+ }
403
+
404
+ close(): Promise<void> {
405
+ return this.client.close()
406
+ }
407
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * stdio transport for the 2026 path (§2.3): one JSON-RPC message per line, responses demultiplexed
3
+ * by id (concurrent calls allowed), stderr passed through and never parsed, a dead child fails every
4
+ * pending call instead of hanging. No headers, no auth.
5
+ */
6
+ import { spawn, type ChildProcess } from "node:child_process"
7
+ import { createInterface } from "node:readline"
8
+ import type { Readable } from "node:stream"
9
+ import type { Transport } from "./client.js"
10
+ import type { Obj } from "./protocol.js"
11
+
12
+ /** The child exited / closed stdout before answering. */
13
+ export class StdioClosedError extends Error {}
14
+
15
+ export class StdioTransport implements Transport {
16
+ private pending = new Map<string, (m: Obj) => void>()
17
+ private err?: Error
18
+ private done: Promise<void>
19
+ private child: ChildProcess
20
+
21
+ constructor(child: ChildProcess) {
22
+ this.child = child
23
+ const rl = createInterface({ input: child.stdout as Readable, crlfDelay: Infinity })
24
+ rl.on("line", (line) => {
25
+ let m: any
26
+ try {
27
+ m = JSON.parse(line)
28
+ } catch {
29
+ return // noise
30
+ }
31
+ if (!m || typeof m !== "object" || m.method !== undefined || m.id === undefined || m.id === null) return // not a response
32
+ const key = JSON.stringify(m.id)
33
+ const f = this.pending.get(key)
34
+ this.pending.delete(key)
35
+ f?.(m)
36
+ })
37
+ this.done = new Promise((r) =>
38
+ rl.once("close", () => {
39
+ this.err ??= new StdioClosedError("stdio server closed its stdout")
40
+ for (const f of this.pending.values()) f({ __closed: true })
41
+ this.pending.clear()
42
+ r()
43
+ }),
44
+ )
45
+ child.stdin?.on("error", () => {}) // EPIPE after the server died surfaces as a closed stdout
46
+ }
47
+
48
+ /** Writes one line and waits for the response with the same id. Never retried. */
49
+ roundTrip(id: number, body: string, timeoutMs: number, signal?: AbortSignal): Promise<Obj> {
50
+ if (this.err) return Promise.reject(this.err)
51
+ const key = JSON.stringify(id)
52
+ return new Promise((resolve, reject) => {
53
+ const cleanup = () => {
54
+ clearTimeout(timer)
55
+ signal?.removeEventListener("abort", onAbort)
56
+ this.pending.delete(key)
57
+ }
58
+ const onAbort = () => {
59
+ cleanup()
60
+ reject(signal?.reason ?? new Error("aborted"))
61
+ }
62
+ const timer = setTimeout(() => {
63
+ cleanup()
64
+ reject(new Error("stdio request timed out"))
65
+ }, timeoutMs)
66
+ if (signal) {
67
+ if (signal.aborted) return onAbort()
68
+ signal.addEventListener("abort", onAbort, { once: true })
69
+ }
70
+ this.pending.set(key, (m) => {
71
+ cleanup()
72
+ if (m.__closed) return reject(this.err)
73
+ if (m.result == null && m.error == null) return reject(new Error("response has neither result nor error"))
74
+ resolve(m)
75
+ })
76
+ this.child.stdin?.write(body + "\n")
77
+ })
78
+ }
79
+
80
+ /** Close stdin, then SIGKILL after 2 s if the child is still alive. */
81
+ async close(): Promise<void> {
82
+ if (this.child.exitCode === null && this.child.signalCode === null) {
83
+ this.child.stdin?.end()
84
+ const exited = new Promise<void>((r) => this.child.once("exit", () => r()))
85
+ const t = setTimeout(() => this.child.kill("SIGKILL"), 2000)
86
+ await exited
87
+ clearTimeout(t)
88
+ }
89
+ this.child.stdout?.destroy()
90
+ await this.done
91
+ }
92
+ }
93
+
94
+ /** Spawns argv (no shell) and returns its transport; rejects if the spawn itself fails. */
95
+ export function spawnStdio(argv: string[], env: NodeJS.ProcessEnv, cwd?: string): Promise<StdioTransport> {
96
+ const [cmd, ...args] = argv
97
+ const child = spawn(cmd, args, { env, cwd, stdio: ["pipe", "pipe", "inherit"] })
98
+ return new Promise((resolve, reject) => {
99
+ child.once("error", reject)
100
+ child.once("spawn", () => resolve(new StdioTransport(child)))
101
+ })
102
+ }