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,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
+ }
package/src/serve.ts CHANGED
@@ -233,6 +233,13 @@ export async function startA2AServer(opts: StartServerOptions): Promise<ServeHan
233
233
  // per request (stateless — sessionIdGenerator undefined). Checked first so
234
234
  // `/mcp` is never shadowed by the A2A POST handler.
235
235
  if (mcp && req.url && (req.url === "/mcp" || req.url.startsWith("/mcp?"))) {
236
+ // Stateless profile: no server-initiated SSE stream exists, so only POST is
237
+ // served. Refuse anything else at once (§7C) instead of holding a GET open.
238
+ if (req.method !== "POST") {
239
+ res.writeHead(405, { Allow: "POST" })
240
+ res.end()
241
+ return
242
+ }
236
243
  void (async () => {
237
244
  const body = req.method === "POST" ? await readBody(req) : ""
238
245
  const parsed = body ? safeJson(body) : undefined
package/src/skill.ts CHANGED
@@ -517,3 +517,53 @@ export function loadSkills(input: string | string[] | LoadSkillsOptions): SkillS
517
517
  },
518
518
  }
519
519
  }
520
+
521
+ /**
522
+ * §7C.2: one skill published through the MCP 2026-07-28 skills extension (SEP-2640) by
523
+ * `mcpHandler({listSkills})`. `files` are paths relative to the skill directory
524
+ * ("/"-separated) and MUST include "SKILL.md". Frontmatter defaults to `{name}`.
525
+ */
526
+ export interface McpSkill {
527
+ name: string
528
+ frontmatter?: Record<string, unknown>
529
+ files: { path: string; text: string }[]
530
+ }
531
+
532
+ /**
533
+ * §7C.2: every skill under `dir` (any depth, `**∕SKILL.md`, symlinks NOT followed) for
534
+ * `mcpHandler({listSkills})`. Frontmatter is `{name, description}` parsed as §3 (trimmed); a
535
+ * skill whose frontmatter has no `name` is skipped. Files are every regular file under the
536
+ * skill directory that is valid UTF-8 (binary files are skipped — the wire carries text),
537
+ * sorted by path; skills sorted by name. A missing `dir` throws.
538
+ */
539
+ export function loadMcpSkills(dir: string): McpSkill[] {
540
+ const utf8 = new TextDecoder("utf-8", { fatal: true })
541
+ const out: McpSkill[] = []
542
+ const walk = (d: string, visit: (full: string) => void) => {
543
+ for (const e of sortEntriesByName(readdirSync(d, { withFileTypes: true }))) {
544
+ const full = path.join(d, e.name)
545
+ if (e.isDirectory()) walk(full, visit)
546
+ else if (e.isFile()) visit(full)
547
+ }
548
+ }
549
+ walk(dir, (p) => {
550
+ if (path.basename(p) !== "SKILL.md") return
551
+ const fm = parseFrontmatter(readFileSync(p, "utf8"))
552
+ if (fm.malformed || !fm.data.name) return
553
+ const root = path.dirname(p)
554
+ const files: { path: string; text: string }[] = []
555
+ walk(root, (fp) => {
556
+ let text: string
557
+ try {
558
+ text = utf8.decode(readFileSync(fp))
559
+ } catch (e) {
560
+ if (e instanceof TypeError) return // not UTF-8
561
+ throw e
562
+ }
563
+ files.push({ path: path.relative(root, fp).split(path.sep).join("/"), text })
564
+ })
565
+ files.sort((a, b) => compareCodePoints(a.path, b.path))
566
+ out.push({ name: fm.data.name, frontmatter: { name: fm.data.name, description: fm.data.description ?? "" }, files })
567
+ })
568
+ return out.sort((a, b) => compareCodePoints(a.name, b.name))
569
+ }
package/src/toolkit.ts CHANGED
@@ -8,7 +8,8 @@ import { loadSkills, type SkillDef, type SkillSource } from "./skill.js"
8
8
  import { selectBuiltins, type BuiltinsConfig } from "./builtin.js"
9
9
  import { agentTools, parseAgentsConfig, type Agent } from "./a2a.js"
10
10
  import { startA2AServer, type A2AConfig, type OnTask, type ServeHandle } from "./serve.js"
11
- import { type MCPServeConfig, type OnCall } from "./mcpserve.js"
11
+ import { serveLocalMcp, type LocalMcpServer, type MCPServeConfig, type OnCall } from "./mcpserve.js"
12
+ import { createMcpHandler, type McpHandler, type McpHandlerConfig } from "./mcphandler.js"
12
13
  import { toAnthropic, toGemini, toOpenAI } from "./adapters.js"
13
14
  import type { Client } from "./client.js"
14
15
  import type { Media } from "./media.js"
@@ -48,6 +49,10 @@ export interface ToolkitOptions {
48
49
  /** §2 Gap 3. Bounds the MCP load: each connect/list is capped by the server timeout and the
49
50
  * whole load aborts promptly if this signal fires. Omit ⇒ unbounded as before. */
50
51
  signal?: AbortSignal
52
+ /** §2A.1a where MCP OAuth tokens live (default: in memory, per load). */
53
+ tokenStore?: import("./mcp2026/auth.js").TokenStore
54
+ /** §2A.1a how `auto` MCP OAuth opens the authorization URL (default: the OS opener). */
55
+ openUrl?: (url: string) => unknown | Promise<unknown>
51
56
  }
52
57
 
53
58
  export class Toolkit {
@@ -83,12 +88,13 @@ export class Toolkit {
83
88
  }
84
89
 
85
90
  static async create(opts: ToolkitOptions = {}): Promise<Toolkit> {
86
- const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor, signal: opts.signal }) : undefined
91
+ const mcp = opts.mcpConfig !== undefined ? await loadMcp(opts.mcpConfig, { waitFor: opts.waitFor, signal: opts.signal, tokenStore: opts.tokenStore, openUrl: opts.openUrl, disableTools: opts.disableTools }) : undefined
87
92
  // Skills come from directories, in-memory data, and/or a lazy provider (§3,
88
93
  // S1). The provider is resolved here (create() is async) and merged with the
89
94
  // data list; a provider failure is isolated so other sources still load.
90
95
  let skill: SkillSource | undefined
91
- if (opts.skillsDir !== undefined || opts.skills !== undefined || opts.skillProvider !== undefined) {
96
+ const mcpSkills = mcp?.skills ?? []
97
+ if (opts.skillsDir !== undefined || opts.skills !== undefined || opts.skillProvider !== undefined || mcpSkills.length > 0) {
92
98
  let providerDefs: SkillDef[] = []
93
99
  if (opts.skillProvider) {
94
100
  try {
@@ -98,7 +104,8 @@ export class Toolkit {
98
104
  providerDefs = []
99
105
  }
100
106
  }
101
- const dataDefs = [...(opts.skills ?? []), ...providerDefs]
107
+ // §2.7 remote skills (MCP skills extension) join the same inventory, after local ones.
108
+ const dataDefs = [...(opts.skills ?? []), ...providerDefs, ...mcpSkills]
102
109
  // disableSkills is sugar over a skillsFilter drop-list: fold each name in as false
103
110
  // WITHOUT overriding an explicit skillsFilter entry (explicit entries win).
104
111
  const filter =
@@ -221,6 +228,26 @@ export class Toolkit {
221
228
  })
222
229
  }
223
230
 
231
+ /**
232
+ * §7C.1 (ADR 0039). An embeddable streamable-HTTP MCP server as a Node request listener
233
+ * `(req, res) => void` (+ `notifyToolsChanged()`), to mount on the host's own server/router
234
+ * behind its own TLS, CORS and OAuth. Every policy is a hook in `cfg`; `serve()` is unchanged.
235
+ * Without `cfg.listTools` it exposes this toolkit's tools.
236
+ */
237
+ mcpHandler(cfg: McpHandlerConfig = {}): McpHandler {
238
+ return createMcpHandler(() => this.tools(), cfg)
239
+ }
240
+
241
+ /**
242
+ * SPEC §8C. Serves this toolkit for a local agent to call: the §7C MCP mapping (tool names
243
+ * verbatim, `skill` stays `skill`, optional `config` filter) on `127.0.0.1:<ephemeral>/mcp`
244
+ * behind a fresh 64-hex bearer token. The caller builds whatever `mcpServers` entry its agent
245
+ * accepts from `{url, token}`; `close()` stops it (idempotent).
246
+ */
247
+ asMcpServer(config?: MCPServeConfig): Promise<LocalMcpServer> {
248
+ return serveLocalMcp(this.tools(), config)
249
+ }
250
+
224
251
  async execute(name: string, args: Record<string, unknown>, ctx?: ToolContext): Promise<ToolResult> {
225
252
  const tool = this.byName.get(name)
226
253
  if (!tool) {
package/src/types.ts CHANGED
@@ -25,6 +25,22 @@ export interface ToolResult {
25
25
  */
26
26
  parts?: ContentPart[]
27
27
  metadata?: Record<string, unknown>
28
+ /**
29
+ * Machine-readable result (MCP `structuredContent`, SPEC §1). Absent ⇒ byte-identical wire.
30
+ * When set and `output` is empty, MCP serve emits the JSON text of `structured` as the text block.
31
+ */
32
+ structured?: unknown
33
+ /** Protocol metadata for the wire result (MCP `_meta`, SPEC §1). Absent ⇒ omitted. */
34
+ meta?: Record<string, unknown>
35
+ }
36
+
37
+ /** MCP tool annotations (SPEC §1, §7C.1), sent verbatim on an MCP `tools/list` entry. */
38
+ export interface ToolAnnotations {
39
+ title?: string
40
+ readOnlyHint?: boolean
41
+ destructiveHint?: boolean
42
+ idempotentHint?: boolean
43
+ openWorldHint?: boolean
28
44
  }
29
45
 
30
46
  export interface ToolContext {
@@ -35,6 +51,9 @@ export interface ToolContext {
35
51
  /** The model's id for this tool call, set by the client loop (§4C). A message's calls run
36
52
  * concurrently, so this is how a tool tells its sibling calls apart. Absent outside the loop. */
37
53
  callId?: string
54
+ /** Set by `toolkit.mcpHandler` (§7C.1): the inbound MCP request this call serves. Read it with
55
+ * `identityFrom(ctx)` / `requestFrom(ctx)` / `mcpSessionIdFrom(ctx)` / `mcpRequestMetaFrom(ctx)`. */
56
+ mcp?: import("./mcphandler.js").McpContext
38
57
  }
39
58
 
40
59
  /**
@@ -133,6 +152,10 @@ export interface Tool {
133
152
  /** Decides per call whether this tool may run alongside other safe calls of the same model
134
153
  * response (SPEC §8 "Tool scheduling"). Absent ⇒ safe only when `hints.readOnly`. */
135
154
  concurrencySafe?: (args: Record<string, unknown>) => boolean
155
+ /** JSON Schema of `ToolResult.structured` (SPEC §1); absent ⇒ omitted everywhere. */
156
+ outputSchema?: Record<string, unknown>
157
+ /** MCP annotations (§7C.1), verbatim on the MCP wire; absent ⇒ derived from `hints`, else omitted. */
158
+ annotations?: ToolAnnotations
136
159
  }
137
160
 
138
161
  export type McpStatus = "connected" | "disabled" | "failed"