toolnexus 0.23.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 (65) hide show
  1. package/README.md +139 -1
  2. package/dist/acp.d.ts +37 -6
  3. package/dist/acp.js +239 -52
  4. package/dist/agents/loop.js +4 -1
  5. package/dist/agents/runtime.js +4 -1
  6. package/dist/client.d.ts +55 -9
  7. package/dist/client.js +154 -41
  8. package/dist/index.d.ts +4 -0
  9. package/dist/index.js +4 -0
  10. package/dist/mcp.d.ts +32 -7
  11. package/dist/mcp.js +125 -36
  12. package/dist/mcp2026/auth.d.ts +137 -0
  13. package/dist/mcp2026/auth.js +446 -0
  14. package/dist/mcp2026/client.d.ts +48 -0
  15. package/dist/mcp2026/client.js +160 -0
  16. package/dist/mcp2026/protocol.d.ts +54 -0
  17. package/dist/mcp2026/protocol.js +113 -0
  18. package/dist/mcp2026/skills.d.ts +5 -0
  19. package/dist/mcp2026/skills.js +25 -0
  20. package/dist/mcp2026/source.d.ts +86 -0
  21. package/dist/mcp2026/source.js +372 -0
  22. package/dist/mcp2026/stdio.d.ts +24 -0
  23. package/dist/mcp2026/stdio.js +99 -0
  24. package/dist/mcp2026/toolsearch.d.ts +5 -0
  25. package/dist/mcp2026/toolsearch.js +58 -0
  26. package/dist/mcphandler.d.ts +169 -0
  27. package/dist/mcphandler.js +1145 -0
  28. package/dist/mcpheader.d.ts +5 -0
  29. package/dist/mcpheader.js +18 -0
  30. package/dist/mcpserve.d.ts +24 -15
  31. package/dist/mcpserve.js +105 -2
  32. package/dist/mcpserverauth.d.ts +48 -0
  33. package/dist/mcpserverauth.js +203 -0
  34. package/dist/media.d.ts +126 -0
  35. package/dist/media.js +540 -0
  36. package/dist/skill.d.ts +21 -0
  37. package/dist/skill.js +44 -0
  38. package/dist/toolconcurrency.d.ts +16 -0
  39. package/dist/toolconcurrency.js +59 -0
  40. package/dist/toolkit.d.ts +23 -1
  41. package/dist/toolkit.js +26 -4
  42. package/dist/types.d.ts +26 -1
  43. package/package.json +5 -3
  44. package/src/acp.ts +258 -55
  45. package/src/agents/loop.ts +4 -1
  46. package/src/agents/runtime.ts +4 -1
  47. package/src/client.ts +196 -48
  48. package/src/index.ts +4 -0
  49. package/src/mcp.ts +162 -43
  50. package/src/mcp2026/auth.ts +473 -0
  51. package/src/mcp2026/client.ts +161 -0
  52. package/src/mcp2026/protocol.ts +135 -0
  53. package/src/mcp2026/skills.ts +22 -0
  54. package/src/mcp2026/source.ts +407 -0
  55. package/src/mcp2026/stdio.ts +102 -0
  56. package/src/mcp2026/toolsearch.ts +56 -0
  57. package/src/mcphandler.ts +1252 -0
  58. package/src/mcpheader.ts +19 -0
  59. package/src/mcpserve.ts +115 -2
  60. package/src/mcpserverauth.ts +216 -0
  61. package/src/media.ts +604 -0
  62. package/src/skill.ts +50 -0
  63. package/src/toolconcurrency.ts +67 -0
  64. package/src/toolkit.ts +35 -5
  65. package/src/types.ts +27 -1
@@ -0,0 +1,19 @@
1
+ /** Internal (not exported from index): MCP 2026-07-28 header helpers shared by the handler and McpServerAuth. */
2
+
3
+ /** Decodes the `=?base64?<std-base64>?=` sentinel (SPEC §2.3); an absent header is not ok. */
4
+ export function decodeHeaderValue(v: string): [string, boolean] {
5
+ if (v.startsWith("=?base64?") && v.endsWith("?=") && v.length >= 11) {
6
+ const b = v.slice(9, -2)
7
+ if (b.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(b)) return ["", false]
8
+ return [Buffer.from(b, "base64").toString("utf8"), true]
9
+ }
10
+ return [v, v !== ""]
11
+ }
12
+
13
+ /** GET a JSON document (200 only). */
14
+ export async function getJson(f: typeof fetch, url: string): Promise<any> {
15
+ const r = await f(url, { headers: { Accept: "application/json" } })
16
+ if (r.status !== 200) throw new Error(`GET ${url}: HTTP ${r.status}`)
17
+ return r.json()
18
+ }
19
+
package/src/mcpserve.ts CHANGED
@@ -12,7 +12,11 @@
12
12
  * server alongside the A2A routes (see serve.ts), built on the same
13
13
  * `@modelcontextprotocol/sdk` the client side already uses.
14
14
  */
15
+ import http from "node:http"
16
+ import type { AddressInfo } from "node:net"
17
+ import { randomBytes, timingSafeEqual } from "node:crypto"
15
18
  import { Server } from "@modelcontextprotocol/sdk/server/index.js"
19
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"
16
20
  import { ListToolsRequestSchema, CallToolRequestSchema, McpError, ErrorCode } from "@modelcontextprotocol/sdk/types.js"
17
21
  import type { Tool, ToolSource, ToolResult } from "./types.js"
18
22
  import type { ContentPart } from "./content.js"
@@ -66,7 +70,11 @@ export function buildMcpServer(tools: Tool[], cfg?: MCPServeConfig, onCall?: OnC
66
70
  )
67
71
 
68
72
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
69
- tools: tools.map((t) => ({ name: t.name, description: t.description, inputSchema: t.inputSchema })),
73
+ tools: tools.map((t) =>
74
+ t.outputSchema
75
+ ? { name: t.name, description: t.description, inputSchema: t.inputSchema, outputSchema: t.outputSchema }
76
+ : { name: t.name, description: t.description, inputSchema: t.inputSchema },
77
+ ),
70
78
  }))
71
79
 
72
80
  server.setRequestHandler(CallToolRequestSchema, async (req, extra) => {
@@ -89,12 +97,36 @@ export function buildMcpServer(tools: Tool[], cfg?: MCPServeConfig, onCall?: OnC
89
97
  // Host callback errors are isolated.
90
98
  }
91
99
  }
92
- return { content: [{ type: "text", text: result.output }, ...partBlocks(result.parts)], isError: result.isError }
100
+ return toolResultToMcp(result) as any
93
101
  })
94
102
 
95
103
  return server
96
104
  }
97
105
 
106
+ /**
107
+ * §7C / §1. Map a ToolResult to an MCP `CallToolResult`: `output` → the text block (or, when
108
+ * `output` is empty and `structured` is set, the JSON text of `structured`), `parts` → extra
109
+ * blocks, `structured` → `structuredContent`, `meta` → `_meta`. With neither set the shape is
110
+ * byte-identical to the pre-§7C.1 result. `omitFalseIsError` drops `isError:false` (the
111
+ * hand-written §7C.1 handler wire, like Go's omitempty); the SDK serve path keeps it.
112
+ */
113
+ export function toolResultToMcp(result: ToolResult, omitFalseIsError = false): Record<string, unknown> {
114
+ let text = result.output ?? ""
115
+ if (result.structured !== undefined && result.structured !== null && text === "") {
116
+ try {
117
+ text = JSON.stringify(result.structured)
118
+ } catch {
119
+ // leave the text empty when structured is not serialisable
120
+ }
121
+ }
122
+ const out: Record<string, unknown> = { content: [{ type: "text", text }, ...partBlocks(result.parts)] }
123
+ if (!omitFalseIsError) out.isError = result.isError
124
+ else if (result.isError) out.isError = true
125
+ if (result.structured !== undefined && result.structured !== null) out.structuredContent = result.structured
126
+ if (result.meta) out._meta = result.meta
127
+ return out
128
+ }
129
+
98
130
  /**
99
131
  * §7C. A served tool's `ToolResult.parts` become MCP content blocks appended after the
100
132
  * text part, in order — image ⇒ image content, audio ⇒ audio content, file ⇒ an embedded
@@ -116,3 +148,84 @@ function partBlocks(parts: ContentPart[] | undefined): any[] {
116
148
  }
117
149
  return out
118
150
  }
151
+
152
+ /** A loopback MCP endpoint for a local agent (SPEC §8C, {@link Toolkit.asMcpServer}). */
153
+ export interface LocalMcpServer {
154
+ /** `http://127.0.0.1:<port>/mcp`. */
155
+ url: string
156
+ /** 64 lowercase hex chars; required as `Authorization: Bearer <token>`. Never logged. */
157
+ token: string
158
+ /** Stops the listener. Idempotent. */
159
+ close(): Promise<void>
160
+ }
161
+
162
+ /**
163
+ * Serves `tools` (filtered by `cfg.tools`) as the stateless §7C streamable-HTTP MCP endpoint on
164
+ * `127.0.0.1:<ephemeral>/mcp` behind a fresh bearer token. Other paths ⇒ 404; missing/wrong
165
+ * token ⇒ 401 + `WWW-Authenticate: Bearer` (constant-time compare); a non-POST ⇒ 405.
166
+ */
167
+ export async function serveLocalMcp(tools: Tool[], cfg?: MCPServeConfig): Promise<LocalMcpServer> {
168
+ const exposed = exposedMcpTools(tools, cfg)
169
+ const token = randomBytes(32).toString("hex")
170
+ const want = Buffer.from("Bearer " + token)
171
+ const server = http.createServer((req, res) => {
172
+ const u = req.url ?? ""
173
+ if (u !== "/mcp" && !u.startsWith("/mcp?")) {
174
+ res.writeHead(404).end("not found")
175
+ return
176
+ }
177
+ const got = Buffer.from(String(req.headers.authorization ?? ""))
178
+ if (got.length !== want.length || !timingSafeEqual(got, want)) {
179
+ res.writeHead(401, { "WWW-Authenticate": "Bearer" }).end("unauthorized")
180
+ return
181
+ }
182
+ if (req.method !== "POST") {
183
+ res.writeHead(405, { Allow: "POST" }).end("method not allowed")
184
+ return
185
+ }
186
+ void (async () => {
187
+ const body = await readBody(req)
188
+ let parsed: unknown
189
+ try {
190
+ parsed = body ? JSON.parse(body) : undefined
191
+ } catch {
192
+ parsed = undefined
193
+ }
194
+ const mcp = buildMcpServer(exposed, cfg)
195
+ const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
196
+ res.on("close", () => {
197
+ void transport.close()
198
+ void mcp.close()
199
+ })
200
+ await mcp.connect(transport)
201
+ await transport.handleRequest(req, res, parsed)
202
+ })().catch(() => {
203
+ if (!res.headersSent) res.writeHead(500).end("mcp error")
204
+ })
205
+ })
206
+ await new Promise<void>((resolve, reject) => {
207
+ server.once("error", reject)
208
+ server.listen(0, "127.0.0.1", () => resolve())
209
+ })
210
+ const port = (server.address() as AddressInfo).port
211
+ let closing: Promise<void> | undefined
212
+ return {
213
+ url: `http://127.0.0.1:${port}/mcp`,
214
+ token,
215
+ close: () =>
216
+ (closing ??= new Promise<void>((resolve) => {
217
+ server.close(() => resolve())
218
+ server.closeAllConnections()
219
+ })),
220
+ }
221
+ }
222
+
223
+ function readBody(req: http.IncomingMessage): Promise<string> {
224
+ return new Promise((resolve, reject) => {
225
+ let data = ""
226
+ req.setEncoding("utf8")
227
+ req.on("data", (c: string) => (data += c))
228
+ req.on("end", () => resolve(data))
229
+ req.on("error", reject)
230
+ })
231
+ }
@@ -0,0 +1,216 @@
1
+ /**
2
+ * MCP server authorization (SPEC §2A.2, §7C.2): an `mcpHandler` as an OAuth 2.1 protected
3
+ * resource. `McpServerAuth` is a plain helper producing the handler's `authenticate` hook and the
4
+ * RFC 9728 metadata listener; the host mounts both. Identity, tenants and token storage stay the
5
+ * host's.
6
+ *
7
+ * const auth = new McpServerAuth({ issuer: "https://login.example", scopes: { "*": ["mcp"] } })
8
+ * const mcp = toolkit.mcpHandler({ protocolVersions: ["2026-07-28"], authenticate: auth.authenticate })
9
+ * const meta = auth.metadata() // mount at /.well-known/oauth-protected-resource[/mcp]
10
+ */
11
+ import { createPublicKey, verify as cryptoVerify, type KeyObject } from "node:crypto"
12
+ import type { IncomingMessage, ServerResponse } from "node:http"
13
+ import { McpAuthError } from "./mcphandler.js"
14
+ import { decodeHeaderValue, getJson } from "./mcpheader.js"
15
+
16
+ /** The verified caller; under McpServerAuth, `identityFrom(ctx)` returns it. */
17
+ export interface McpPrincipal {
18
+ subject: string
19
+ scopes: string[]
20
+ claims?: Record<string, unknown>
21
+ }
22
+
23
+ export interface McpServerAuthOptions {
24
+ /** The authorization server's issuer (advertised in the PRM, and the JWT `iss` required). */
25
+ issuer: string
26
+ /** Canonical resource URI (PRM `resource`, JWT `aud`). Omit ⇒ `<scheme>://<Host><resourcePath>` per request. */
27
+ audience?: string
28
+ /** Where the handler is mounted. Default "/mcp". */
29
+ resourcePath?: string
30
+ /** "*" is required on every request (and advertised); a tool name adds scopes for that tools/call. */
31
+ scopes?: Record<string, string[]>
32
+ /** Replaces the default RS256/JWKS verifier (opaque tokens, introspection). Throw ⇒ invalid_token. */
33
+ verify?: (req: IncomingMessage, token: string, audience: string) => Promise<McpPrincipal> | McpPrincipal
34
+ /** Fetches AS metadata and JWKS. Default: global fetch with a 10 s timeout. */
35
+ fetch?: typeof fetch
36
+ }
37
+
38
+ const PRM_PATH = "/.well-known/oauth-protected-resource"
39
+
40
+ function header(req: IncomingMessage, name: string): string {
41
+ const v = req.headers[name.toLowerCase()]
42
+ return Array.isArray(v) ? (v[0] ?? "") : (v ?? "")
43
+ }
44
+
45
+ function origin(req: IncomingMessage): string {
46
+ const scheme = (req.socket as { encrypted?: boolean }).encrypted ? "https" : "http"
47
+ return `${scheme}://${header(req, "Host")}`
48
+ }
49
+
50
+ /** Protects an `mcpHandler` with bearer tokens (SPEC §2A.2). */
51
+ export class McpServerAuth {
52
+ readonly issuer: string
53
+ readonly audience?: string
54
+ readonly resourcePath: string
55
+ readonly scopes: Record<string, string[]>
56
+ private readonly verifyHook?: McpServerAuthOptions["verify"]
57
+ private readonly fetchImpl: typeof fetch
58
+ private keys?: Map<string, KeyObject>
59
+ private fetched = 0
60
+
61
+ constructor(opts: McpServerAuthOptions) {
62
+ this.issuer = opts.issuer
63
+ this.audience = opts.audience || undefined
64
+ this.resourcePath = opts.resourcePath || "/mcp"
65
+ this.scopes = opts.scopes ?? {}
66
+ this.verifyHook = opts.verify
67
+ this.fetchImpl = opts.fetch ?? ((u, init) => fetch(u, { ...init, signal: AbortSignal.timeout(10_000) }))
68
+ }
69
+
70
+ private audienceFor(req: IncomingMessage): string {
71
+ return this.audience ?? origin(req) + this.resourcePath
72
+ }
73
+
74
+ /** The RFC 9728 metadata URL the challenge names. */
75
+ metadataUrl(req: IncomingMessage): string {
76
+ if (this.audience) {
77
+ try {
78
+ const u = new URL(this.audience)
79
+ if (u.host) {
80
+ const path = /^[^:]+:\/\/[^/?#]*\//.test(this.audience) ? u.pathname : ""
81
+ return `${u.protocol}//${u.host}${PRM_PATH}${path}`
82
+ }
83
+ } catch {
84
+ // fall through to the request-derived URL
85
+ }
86
+ }
87
+ return origin(req) + PRM_PATH + this.resourcePath
88
+ }
89
+
90
+ /** Request listener serving the protected resource metadata at the two PRM paths; anything else 404. */
91
+ metadata(): (req: IncomingMessage, res: ServerResponse) => void {
92
+ return (req, res) => {
93
+ const path = (req.url ?? "/").split("?")[0]
94
+ if (req.method !== "GET" || (path !== PRM_PATH && path !== PRM_PATH + this.resourcePath)) {
95
+ res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" }).end("404 page not found\n")
96
+ return
97
+ }
98
+ res.writeHead(200, { "Content-Type": "application/json" })
99
+ res.end(
100
+ JSON.stringify({
101
+ resource: this.audienceFor(req),
102
+ authorization_servers: [this.issuer],
103
+ scopes_supported: this.scopes["*"] ?? [],
104
+ bearer_methods_supported: ["header"],
105
+ }) + "\n",
106
+ )
107
+ }
108
+ }
109
+
110
+ private challenge(req: IncomingMessage, status: number, scopes: string[] | undefined, err: string): McpAuthError {
111
+ let v = `Bearer resource_metadata="${this.metadataUrl(req)}"`
112
+ if (scopes && scopes.length > 0) v += `, scope="${scopes.join(" ")}"`
113
+ if (err) v += `, error="${err}"`
114
+ return new McpAuthError(status, v, err)
115
+ }
116
+
117
+ /**
118
+ * The `mcpHandler` `authenticate` hook (bound). Missing token ⇒ 401; invalid ⇒ 401
119
+ * error="invalid_token"; a missing scope ⇒ 403 error="insufficient_scope" naming the full needed
120
+ * set. Per-tool scopes come from the Mcp-Method / Mcp-Name headers (the 2026-07-28 wire).
121
+ */
122
+ readonly authenticate = async (req: IncomingMessage): Promise<McpPrincipal> => {
123
+ const authz = header(req, "Authorization")
124
+ const tok = authz.startsWith("Bearer ") ? authz.slice(7) : ""
125
+ if (tok.trim() === "") throw this.challenge(req, 401, this.scopes["*"], "")
126
+ let p: McpPrincipal
127
+ try {
128
+ const aud = this.audienceFor(req)
129
+ p = this.verifyHook ? await this.verifyHook(req, tok, aud) : await this.verifyJwt(tok, aud)
130
+ } catch {
131
+ throw this.challenge(req, 401, this.scopes["*"], "invalid_token")
132
+ }
133
+ const need = [...(this.scopes["*"] ?? [])]
134
+ if (header(req, "Mcp-Method") === "tools/call") {
135
+ const [name, ok] = decodeHeaderValue(header(req, "Mcp-Name"))
136
+ if (ok) for (const s of this.scopes[name] ?? []) if (!need.includes(s)) need.push(s)
137
+ }
138
+ const have = p.scopes ?? []
139
+ if (need.some((s) => !have.includes(s))) throw this.challenge(req, 403, need, "insufficient_scope")
140
+ return p
141
+ }
142
+
143
+ private async verifyJwt(tok: string, aud: string): Promise<McpPrincipal> {
144
+ const parts = tok.split(".")
145
+ if (parts.length !== 3) throw new Error("malformed jwt")
146
+ const hdr = decodeSeg(parts[0]!)
147
+ const claims = decodeSeg(parts[1]!)
148
+ if (!hdr || !claims || hdr.alg !== "RS256") throw new Error("bad jwt header or claims")
149
+ const key = await this.key(typeof hdr.kid === "string" ? hdr.kid : "")
150
+ if (!/^[A-Za-z0-9_-]*$/.test(parts[2]!)) throw new Error("bad signature encoding")
151
+ const sig = Buffer.from(parts[2]!, "base64url")
152
+ if (!cryptoVerify("RSA-SHA256", Buffer.from(parts[0] + "." + parts[1]), key, sig)) throw new Error("bad signature")
153
+ if (claims.iss !== this.issuer) throw new Error("wrong issuer")
154
+ const a = claims.aud
155
+ if (!(typeof a === "string" ? a === aud : Array.isArray(a) && a.includes(aud))) throw new Error("wrong audience")
156
+ const exp = typeof claims.exp === "number" ? Math.trunc(claims.exp) : 0
157
+ if (exp <= Math.floor(Date.now() / 1000)) throw new Error("expired")
158
+ const scope = typeof claims.scope === "string" ? claims.scope : ""
159
+ return {
160
+ subject: typeof claims.sub === "string" ? claims.sub : "",
161
+ scopes: scope.split(/\s+/).filter(Boolean),
162
+ claims,
163
+ }
164
+ }
165
+
166
+ /** The JWKS key for kid; an unknown kid refetches at most every 30 s. */
167
+ private async key(kid: string): Promise<KeyObject> {
168
+ const k = this.keys?.get(kid)
169
+ if (k) return k
170
+ if (this.keys && Date.now() - this.fetched < 30_000) throw new Error("unknown kid")
171
+ const md = await discoverAs(this.fetchImpl, this.issuer)
172
+ const jwks = await getJson(this.fetchImpl, String(md.jwks_uri ?? ""))
173
+ const keys = new Map<string, KeyObject>()
174
+ for (const j of Array.isArray(jwks?.keys) ? jwks.keys : []) {
175
+ if (j?.kty !== "RSA" || typeof j.n !== "string" || typeof j.e !== "string") continue
176
+ try {
177
+ keys.set(String(j.kid ?? ""), createPublicKey({ key: { kty: "RSA", n: j.n, e: j.e }, format: "jwk" }))
178
+ } catch {
179
+ // skip a malformed key
180
+ }
181
+ }
182
+ this.keys = keys
183
+ this.fetched = Date.now()
184
+ const got = keys.get(kid)
185
+ if (!got) throw new Error("unknown kid")
186
+ return got
187
+ }
188
+ }
189
+
190
+ function decodeSeg(s: string): Record<string, any> | undefined {
191
+ if (!/^[A-Za-z0-9_-]+$/.test(s)) return undefined
192
+ try {
193
+ const v = JSON.parse(Buffer.from(s, "base64url").toString("utf8"))
194
+ return v && typeof v === "object" && !Array.isArray(v) ? v : undefined
195
+ } catch {
196
+ return undefined
197
+ }
198
+ }
199
+
200
+ /** AS metadata in SPEC §2A.1 step 3 order; the metadata `issuer` must equal the issuer. */
201
+ async function discoverAs(f: typeof fetch, issuer: string): Promise<Record<string, unknown>> {
202
+ const u = new URL(issuer)
203
+ const org = `${u.protocol}//${u.host}`
204
+ const path = (/^[^:]+:\/\/[^/?#]*\//.test(issuer) ? u.pathname : "").replace(/\/$/, "")
205
+ const cands = [`${org}/.well-known/oauth-authorization-server${path}`, `${org}/.well-known/openid-configuration${path}`]
206
+ if (path) cands.push(`${org}${path}/.well-known/openid-configuration`)
207
+ for (const c of cands) {
208
+ try {
209
+ const md = await getJson(f, c)
210
+ if (md && md.issuer === issuer) return md
211
+ } catch {
212
+ // next candidate
213
+ }
214
+ }
215
+ throw new Error("authorization server metadata not found for " + issuer)
216
+ }