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.
- package/README.md +151 -1
- package/dist/acp.d.ts +37 -6
- package/dist/acp.js +239 -52
- package/dist/classifier.d.ts +189 -31
- package/dist/classifier.js +589 -30
- package/dist/client.d.ts +38 -8
- package/dist/client.js +102 -23
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/judge.d.ts +6 -3
- package/dist/judge.js +13 -1
- package/dist/mcp.d.ts +32 -7
- package/dist/mcp.js +125 -36
- package/dist/mcp2026/auth.d.ts +137 -0
- package/dist/mcp2026/auth.js +446 -0
- package/dist/mcp2026/client.d.ts +48 -0
- package/dist/mcp2026/client.js +160 -0
- package/dist/mcp2026/protocol.d.ts +54 -0
- package/dist/mcp2026/protocol.js +113 -0
- package/dist/mcp2026/skills.d.ts +5 -0
- package/dist/mcp2026/skills.js +25 -0
- package/dist/mcp2026/source.d.ts +86 -0
- package/dist/mcp2026/source.js +372 -0
- package/dist/mcp2026/stdio.d.ts +24 -0
- package/dist/mcp2026/stdio.js +99 -0
- package/dist/mcp2026/toolsearch.d.ts +5 -0
- package/dist/mcp2026/toolsearch.js +58 -0
- package/dist/mcphandler.d.ts +169 -0
- package/dist/mcphandler.js +1145 -0
- package/dist/mcpheader.d.ts +5 -0
- package/dist/mcpheader.js +18 -0
- package/dist/mcpserve.d.ts +24 -15
- package/dist/mcpserve.js +105 -2
- package/dist/mcpserverauth.d.ts +48 -0
- package/dist/mcpserverauth.js +203 -0
- package/dist/serve.js +7 -0
- package/dist/skill.d.ts +21 -0
- package/dist/skill.js +44 -0
- package/dist/toolkit.d.ts +20 -1
- package/dist/toolkit.js +25 -3
- package/dist/types.d.ts +22 -0
- package/package.json +4 -3
- package/src/acp.ts +258 -55
- package/src/classifier.ts +677 -46
- package/src/client.ts +130 -27
- package/src/index.ts +2 -0
- package/src/judge.ts +19 -4
- package/src/mcp.ts +162 -43
- package/src/mcp2026/auth.ts +473 -0
- package/src/mcp2026/client.ts +161 -0
- package/src/mcp2026/protocol.ts +135 -0
- package/src/mcp2026/skills.ts +22 -0
- package/src/mcp2026/source.ts +407 -0
- package/src/mcp2026/stdio.ts +102 -0
- package/src/mcp2026/toolsearch.ts +56 -0
- package/src/mcphandler.ts +1252 -0
- package/src/mcpheader.ts +19 -0
- package/src/mcpserve.ts +115 -2
- package/src/mcpserverauth.ts +216 -0
- package/src/serve.ts +7 -0
- package/src/skill.ts +50 -0
- package/src/toolkit.ts +31 -4
- package/src/types.ts +23 -0
package/src/mcpheader.ts
ADDED
|
@@ -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) =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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"
|