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,473 @@
1
+ // Client-side OAuth for MCP 2026-07-28 (basic/authorization). Node stdlib only.
2
+ // Flow: 401/403 -> WWW-Authenticate -> PRM (RFC 9728) -> AS metadata (RFC 8414 / OIDC)
3
+ // -> client id (pre-registered | CIMD) -> code + PKCE S256 + resource (RFC 8707)
4
+ // -> iss (RFC 9207) + state check -> token -> retry with Bearer.
5
+ import { spawn } from "node:child_process"
6
+ import { createHash, randomBytes } from "node:crypto"
7
+ import http from "node:http"
8
+ import { defaultHttp, getJSON, Mutex, type HttpFn, type Obj } from "./protocol.js"
9
+
10
+ function closeServer(hs: http.Server): Promise<void> {
11
+ return new Promise((r) => {
12
+ hs.close(() => r())
13
+ hs.closeAllConnections()
14
+ })
15
+ }
16
+
17
+ const inspect = Symbol.for("nodejs.util.inspect.custom")
18
+
19
+ /** Bound to the issuer that minted it and the resource it is for. Never log it (it prints redacted). */
20
+ export class Token {
21
+ accessToken = ""
22
+ refreshToken = ""
23
+ /** ms since epoch; 0 = no expiry. */
24
+ expiresAt = 0
25
+ scopes: string[] = []
26
+ issuer = ""
27
+ clientId = ""
28
+ /** The protected resource (server URI) the token is bound to. */
29
+ resource = ""
30
+ constructor(init: Partial<Token> = {}) {
31
+ Object.assign(this, init)
32
+ }
33
+ toString(): string {
34
+ return `Token{issuer=${this.issuer}, redacted}`
35
+ }
36
+ toJSON(): string {
37
+ return this.toString()
38
+ }
39
+ [inspect](): string {
40
+ return this.toString()
41
+ }
42
+ }
43
+
44
+ /** Persists tokens per resource. Implementations must keep them confidential. Sync or async. */
45
+ export interface TokenStore {
46
+ load(resource: string): Token | null | undefined | Promise<Token | null | undefined>
47
+ save(resource: string, t: Token): void | Promise<void>
48
+ delete(resource: string): void | Promise<void>
49
+ }
50
+
51
+ export class MemoryTokenStore implements TokenStore {
52
+ private m = new Map<string, Token>()
53
+ load(r: string) { return this.m.get(r) }
54
+ save(r: string, t: Token) { this.m.set(r, t) }
55
+ delete(r: string) { this.m.delete(r) }
56
+ }
57
+
58
+ /** Mirrors toolnexus SPEC §10 Request (kind "authorization"). */
59
+ export interface Pending {
60
+ id: string
61
+ kind: "authorization"
62
+ prompt: string
63
+ url: string
64
+ expiresAt: string
65
+ }
66
+
67
+ /** Thrown instead of blocking when the authorizer pauses. Call oauth.resume(id, callbackURL). */
68
+ export class PendingError extends Error {
69
+ pending: Pending
70
+ constructor(p: Pending) {
71
+ super("authorization pending: " + p.prompt)
72
+ this.pending = p
73
+ }
74
+ }
75
+
76
+ /** The Pending inside err, or undefined. */
77
+ export function pendingOf(err: unknown): Pending | undefined {
78
+ return err instanceof PendingError ? err.pending : undefined
79
+ }
80
+
81
+ /** What a pausing AuthSession.wait throws. */
82
+ export const PAUSE = new Error("authorization paused")
83
+
84
+ /** Decides how the user reaches the authorization URL and how the callback comes back. */
85
+ export interface Authorizer {
86
+ begin(): Promise<AuthSession>
87
+ }
88
+ export interface AuthSession {
89
+ redirectUri: string
90
+ /** Resolves with the full callback URL, or throws PAUSE to hand a Pending to the caller. */
91
+ wait(p: Pending): Promise<string>
92
+ close(): void | Promise<void>
93
+ }
94
+
95
+ /** Never blocks: every authorization becomes a Pending. */
96
+ export class PauseAuthorizer implements Authorizer {
97
+ redirect: string
98
+ constructor(o: { redirect: string }) {
99
+ this.redirect = o.redirect
100
+ }
101
+ async begin(): Promise<AuthSession> {
102
+ return { redirectUri: this.redirect, wait: async () => { throw PAUSE }, close: () => {} }
103
+ }
104
+ }
105
+
106
+ /** Opens a browser and listens on 127.0.0.1:<random>/callback (RFC 8252 §7.3). */
107
+ export class LoopbackAuthorizer implements Authorizer {
108
+ open: (url: string) => unknown | Promise<unknown> // default = system browser
109
+ timeoutMs = 5 * 60_000
110
+ constructor(o: { open?: (url: string) => unknown | Promise<unknown> } = {}) {
111
+ this.open = o.open ?? openBrowser
112
+ }
113
+ async begin(): Promise<AuthSession> {
114
+ let deliver!: (u: string) => void
115
+ const cb = new Promise<string>((r) => (deliver = r))
116
+ const hs = http.createServer((req, res) => {
117
+ if (new URL(req.url ?? "/", "http://x").pathname !== "/callback") return void res.writeHead(404).end()
118
+ deliver(`http://${req.headers.host}${req.url}`)
119
+ res.end("Authorization received. You can close this window.")
120
+ })
121
+ await new Promise<void>((r, j) => hs.once("error", j).listen(0, "127.0.0.1", () => r()))
122
+ const port = (hs.address() as { port: number }).port
123
+ const timeoutMs = this.timeoutMs
124
+ const open = this.open
125
+ return {
126
+ redirectUri: `http://127.0.0.1:${port}/callback`,
127
+ close: () => closeServer(hs),
128
+ async wait(p) {
129
+ let timer: NodeJS.Timeout | undefined
130
+ const timeout = new Promise<never>((_, j) => (timer = setTimeout(() => j(new Error("authorization timed out")), timeoutMs)))
131
+ // the opener may block (tests follow redirects synchronously); a failed opener must not leave
132
+ // us waiting 5 minutes for a callback that cannot come
133
+ const opened = Promise.resolve().then(() => open(p.url)).then(
134
+ () => cb,
135
+ (e) => { throw new Error(`open authorization URL: ${(e as Error)?.message ?? e}`) },
136
+ )
137
+ try {
138
+ return await Promise.race([cb, opened, timeout])
139
+ } finally {
140
+ clearTimeout(timer)
141
+ }
142
+ },
143
+ }
144
+ }
145
+ }
146
+
147
+ function openBrowser(u: string): Promise<void> {
148
+ const [cmd, args] = process.platform === "darwin" ? ["open", [u]] : process.platform === "win32" ? ["rundll32", ["url.dll,FileProtocolHandler", u]] : ["xdg-open", [u]]
149
+ return new Promise((r, j) => spawn(cmd as string, args as string[], { stdio: "ignore", detached: true }).once("error", j).once("spawn", r).unref())
150
+ }
151
+
152
+ export interface OAuthOptions {
153
+ authorizer?: Authorizer // default = new LoopbackAuthorizer() (system browser + loopback callback)
154
+ clientId?: string // pre-registered client_id for any issuer not in clientIds
155
+ clientSecret?: string // optional: makes the pre-registered client confidential. Never logged.
156
+ tokenAuthMethod?: "" | "none" | "client_secret_basic" | "client_secret_post" // "" = basic if a secret else none
157
+ /** Drops the RFC 8707 `resource` from refresh_token requests (Entra rejects it; python-sdk #2578). */
158
+ omitRefreshResource?: boolean
159
+ store?: TokenStore // default = in-memory
160
+ clientIds?: Record<string, string> // issuer -> pre-registered client_id (bound to that issuer)
161
+ clientMetadataUrl?: string // CIMD client_id (HTTPS URL), used when the AS advertises support
162
+ http?: HttpFn // for PRM / AS / token calls
163
+ allowHttp?: boolean // allow non-HTTPS AS endpoints (dev only; the spec requires HTTPS)
164
+ }
165
+
166
+ interface AuthFlow {
167
+ resource: string; issuer: string; clientId: string; redirect: string; verifier: string; state: string
168
+ tokenEndpoint: string; issSupported: boolean; scopes: string[]; expires: number
169
+ retry: () => Promise<Obj>
170
+ }
171
+
172
+ /** The client-side auth engine; attach it as client.auth. */
173
+ export class OAuth {
174
+ authorizer: Authorizer
175
+ clientId: string
176
+ clientSecret: string
177
+ tokenAuthMethod: string
178
+ omitRefreshResource: boolean
179
+ store: TokenStore
180
+ clientIds: Record<string, string>
181
+ clientMetadataUrl: string
182
+ http: HttpFn
183
+ allowHttp: boolean
184
+ private authMu = new Mutex() // serialises refresh + interactive authorization
185
+ private issuer = new Map<string, string>() // resource -> discovered issuer (only then do we send a stored token)
186
+ /** @internal paused flows by pending id (exposed for tests) */
187
+ flows = new Map<string, AuthFlow>()
188
+
189
+ constructor(o: OAuthOptions = {}) {
190
+ this.authorizer = o.authorizer ?? new LoopbackAuthorizer()
191
+ this.clientId = o.clientId ?? ""
192
+ this.clientSecret = o.clientSecret ?? ""
193
+ this.tokenAuthMethod = o.tokenAuthMethod ?? ""
194
+ this.omitRefreshResource = o.omitRefreshResource ?? false
195
+ this.store = o.store ?? new MemoryTokenStore()
196
+ this.clientIds = o.clientIds ?? {}
197
+ this.clientMetadataUrl = o.clientMetadataUrl ?? ""
198
+ this.http = o.http ?? defaultHttp
199
+ this.allowHttp = o.allowHttp ?? false
200
+ }
201
+
202
+ /** The token to send for resource, refreshing if expired. "" = send none. */
203
+ async bearer(resource: string): Promise<string> {
204
+ const iss = this.issuer.get(resource)
205
+ let t = (await this.store.load(resource)) ?? undefined
206
+ if (!t || !iss || t.issuer !== iss) return "" // only tokens from this server's AS (spec: Token Handling)
207
+ if (expired(t)) {
208
+ // one refresh for N concurrent callers (refresh tokens rotate: a 2nd refresh would fail)
209
+ return this.authMu.lock(async () => {
210
+ let cur = (await this.store.load(resource)) ?? undefined
211
+ if (!cur) return ""
212
+ if (expired(cur)) {
213
+ try {
214
+ await this.refresh(cur)
215
+ } catch {
216
+ return ""
217
+ }
218
+ cur = (await this.store.load(resource)) ?? undefined
219
+ }
220
+ return cur?.accessToken ?? ""
221
+ })
222
+ }
223
+ return t.accessToken
224
+ }
225
+
226
+ private async refresh(t: Token): Promise<void> {
227
+ if (!t.refreshToken) throw new Error("no refresh token")
228
+ const md = await discoverAS(this.http, t.issuer)
229
+ const form = new URLSearchParams({ grant_type: "refresh_token", refresh_token: t.refreshToken, client_id: t.clientId })
230
+ if (!this.omitRefreshResource) form.set("resource", t.resource)
231
+ let nt: Token
232
+ try {
233
+ nt = await this.tokenRequest(String(md.token_endpoint ?? ""), form, t.issuer, t.resource, t.clientId, t.scopes)
234
+ } catch (e) {
235
+ await this.store.delete(t.resource)
236
+ throw e
237
+ }
238
+ if (!nt.refreshToken) nt.refreshToken = t.refreshToken
239
+ await this.store.save(t.resource, nt)
240
+ }
241
+
242
+ /** @internal Handles a 401/403. Resolves = retry the request now; throws PendingError = paused. */
243
+ challenge(resource: string, status: number, www: string, sent: string, retry: () => Promise<Obj>): Promise<void> {
244
+ const params = parseChallenge(www)
245
+ // N concurrent 401s -> one authorization, the rest retry with its token
246
+ return this.authMu.lock(async () => {
247
+ let cur = (await this.store.load(resource)) ?? undefined
248
+ const known = this.issuer.get(resource)
249
+ if (cur && known && cur.issuer === known && cur.accessToken !== sent && !expired(cur)) return // someone else refreshed/authorized meanwhile
250
+ if (status === 401 && cur?.refreshToken && params.error === "invalid_token") {
251
+ try {
252
+ await this.refresh(cur)
253
+ return
254
+ } catch {
255
+ cur = undefined
256
+ }
257
+ }
258
+ if (status === 403 && params.error !== "insufficient_scope") throw new Error(`forbidden: ${params.error ?? ""}`)
259
+ const prm = await this.fetchPRM(resource, params.resource_metadata ?? "")
260
+ const servers = Array.isArray(prm.authorization_servers) ? prm.authorization_servers : []
261
+ if (servers.length === 0) throw new Error("PRM lists no authorization_servers")
262
+ const issuer = String(servers[0]) // RFC 9728 §7.6: client's choice; we take the first
263
+ if (!this.allowHttp && !issuer.startsWith("https://")) throw new Error("authorization server must use https")
264
+ const md = await discoverAS(this.http, issuer)
265
+ const methods = md.code_challenge_methods_supported
266
+ if (!Array.isArray(methods) || !methods.includes("S256")) throw new Error("authorization server does not advertise PKCE S256; refusing")
267
+ this.issuer.set(resource, issuer)
268
+ if (status === 401 && cur && cur.issuer === issuer && !params.error) return // first request after restart: issuer now known
269
+ // Scope selection: challenge scope, else scopes_supported; on step-up union with previous.
270
+ let scopes = (params.scope ?? "").split(/\s+/).filter(Boolean)
271
+ if (scopes.length === 0) scopes = strings(prm.scopes_supported)
272
+ if (cur && status === 403) for (const s of cur.scopes) if (!scopes.includes(s)) scopes.push(s)
273
+ let clientId = this.clientIds[issuer] || this.clientId
274
+ const cimd = md.client_id_metadata_document_supported === true
275
+ if (!clientId && this.clientMetadataUrl && cimd) clientId = this.clientMetadataUrl
276
+ if (!clientId) {
277
+ // no DCR, ever: the user registers an app once and configures its id
278
+ const why = cimd ? "auth.clientMetadataUrl is not set" : "it does not support Client ID Metadata Documents"
279
+ throw new Error(`no client identity for ${issuer}: ${why} and no client id is configured — register an app with ${issuer} and set auth.clientId (dynamic client registration is not supported)`)
280
+ }
281
+ const sess = await this.authorizer.begin()
282
+ const f: AuthFlow = {
283
+ resource, issuer, clientId, redirect: sess.redirectUri, verifier: randStr(32), state: randStr(16),
284
+ tokenEndpoint: String(md.token_endpoint ?? ""), scopes, retry,
285
+ issSupported: md.authorization_response_iss_parameter_supported === true, expires: Date.now() + 10 * 60_000,
286
+ }
287
+ const q = new URLSearchParams({
288
+ response_type: "code", client_id: clientId, redirect_uri: f.redirect, state: f.state,
289
+ code_challenge: createHash("sha256").update(f.verifier).digest("base64url"), code_challenge_method: "S256", resource,
290
+ })
291
+ if (scopes.length) q.set("scope", scopes.join(" "))
292
+ const p: Pending = {
293
+ id: "auth-" + randStr(12), kind: "authorization", url: String(md.authorization_endpoint ?? "") + "?" + q.toString(),
294
+ prompt: "Authorize access to " + resource, expiresAt: new Date(f.expires).toISOString().replace(/\.\d+Z$/, "Z"),
295
+ }
296
+ let cb: string
297
+ try {
298
+ cb = await sess.wait(p)
299
+ } catch (e) {
300
+ if (e === PAUSE) {
301
+ const now = Date.now()
302
+ for (const [id, old] of this.flows) if (now > old.expires) this.flows.delete(id) // drop abandoned pendings
303
+ this.flows.set(p.id, f)
304
+ throw new PendingError(p)
305
+ }
306
+ await sess.close()
307
+ throw e
308
+ }
309
+ await sess.close()
310
+ await this.complete(f, cb)
311
+ })
312
+ }
313
+
314
+ /** @internal */
315
+ setRetry(id: string, f: () => Promise<Obj>): void {
316
+ const fl = this.flows.get(id)
317
+ if (fl) fl.retry = f
318
+ }
319
+
320
+ /** Finishes a paused authorization and re-runs the original operation, returning its result. */
321
+ async resume(id: string, callbackUrl: string): Promise<Obj> {
322
+ const f = this.flows.get(id)
323
+ this.flows.delete(id)
324
+ if (!f) throw new Error(`unknown pending authorization "${id}"`)
325
+ if (Date.now() > f.expires) throw new Error("pending authorization expired")
326
+ await this.complete(f, callbackUrl)
327
+ return f.retry()
328
+ }
329
+
330
+ private async complete(f: AuthFlow, callbackUrl: string): Promise<void> {
331
+ const q = new URL(callbackUrl).searchParams
332
+ // RFC 9207 §2.4 table (spec: Authorization Response Validation) — before anything else, error responses included.
333
+ if (q.has("iss")) {
334
+ if (q.get("iss") !== f.issuer) throw new Error("authorization response iss mismatch (possible mix-up attack)") // no normalization
335
+ } else if (f.issSupported) throw new Error("authorization response missing iss")
336
+ if ((q.get("state") ?? "") !== f.state) throw new Error("authorization response state mismatch")
337
+ const err = q.get("error")
338
+ if (err) throw new Error(`authorization denied: ${err}`)
339
+ const t = await this.tokenRequest(f.tokenEndpoint, new URLSearchParams({
340
+ grant_type: "authorization_code", code: q.get("code") ?? "", redirect_uri: f.redirect,
341
+ client_id: f.clientId, code_verifier: f.verifier, resource: f.resource,
342
+ }), f.issuer, f.resource, f.clientId, f.scopes)
343
+ await this.store.save(f.resource, t)
344
+ }
345
+
346
+ private async tokenRequest(ep: string, form: URLSearchParams, issuer: string, resource: string, clientId: string, scopes: string[]): Promise<Token> {
347
+ let method = this.tokenAuthMethod
348
+ if (clientId === this.clientMetadataUrl || !this.clientSecret) method = "none" // CIMD + secretless clients are public
349
+ else if (!method) method = "client_secret_basic"
350
+ if (method === "client_secret_post") form.set("client_secret", this.clientSecret)
351
+ const headers: Record<string, string> = {
352
+ "Content-Type": "application/x-www-form-urlencoded",
353
+ Accept: "application/json", // GitHub answers form-encoded without it
354
+ }
355
+ if (method === "client_secret_basic") {
356
+ form.delete("client_id") // RFC 6749 §2.3.1: authenticate with one method only
357
+ headers.Authorization = "Basic " + Buffer.from(formEscape(clientId) + ":" + formEscape(this.clientSecret)).toString("base64")
358
+ }
359
+ const resp = await this.http({ method: "POST", url: ep, headers, body: form.toString() })
360
+ let tr: Obj | undefined
361
+ try {
362
+ tr = JSON.parse(resp.body)
363
+ } catch {
364
+ if ((resp.headers["content-type"] ?? "").startsWith("application/x-www-form-urlencoded")) {
365
+ // RFC 6749 §5.1 mandates JSON, but some ASes (GitHub) still default to form encoding.
366
+ tr = Object.fromEntries(new URLSearchParams(resp.body))
367
+ }
368
+ }
369
+ if (!tr || resp.status !== 200 || !tr.access_token) {
370
+ throw new Error(`token endpoint: HTTP ${resp.status} ${typeof tr?.error === "string" ? tr.error : ""}`) // never include the body
371
+ }
372
+ const t = new Token({ accessToken: String(tr.access_token), refreshToken: String(tr.refresh_token ?? ""), issuer, resource, clientId, scopes })
373
+ if (typeof tr.scope === "string" && tr.scope) t.scopes = tr.scope.split(/\s+/).filter(Boolean)
374
+ const ei = Number(tr.expires_in)
375
+ if (ei > 0) t.expiresAt = Date.now() + ei * 1000
376
+ return t
377
+ }
378
+
379
+ /** PRM: header URL if present, else path-inserted well-known, else root (spec: PRM Discovery Requirements). */
380
+ async fetchPRM(resource: string, hinted: string): Promise<Obj> {
381
+ let cands = [hinted]
382
+ if (!hinted) {
383
+ const u = new URL(resource)
384
+ const root = `${u.protocol}//${u.host}/.well-known/oauth-protected-resource`
385
+ const p = u.pathname.replace(/\/$/, "")
386
+ cands = p ? [root + p, root] : [root]
387
+ }
388
+ let last: unknown
389
+ for (const c of cands) {
390
+ let m: Obj
391
+ try {
392
+ m = await getJSON(this.http, c)
393
+ } catch (e) {
394
+ last = e
395
+ continue
396
+ }
397
+ if (m.resource !== resource) throw new Error(`PRM resource ${m.resource} does not match ${resource}`) // RFC 9728 §3.3
398
+ return m
399
+ }
400
+ throw new Error(`protected resource metadata: ${(last as Error)?.message ?? last}`)
401
+ }
402
+ }
403
+
404
+ function expired(t: Token): boolean {
405
+ return t.expiresAt > 0 && Date.now() > t.expiresAt - 10_000
406
+ }
407
+
408
+ /** RFC 8414 then OIDC endpoints in the spec's order, with an issuer equality check. */
409
+ export async function discoverAS(httpFn: HttpFn, issuer: string): Promise<Obj> {
410
+ const u = new URL(issuer)
411
+ const base = `${u.protocol}//${u.host}`
412
+ const p = u.pathname.replace(/\/$/, "")
413
+ const cands = p
414
+ ? [base + "/.well-known/oauth-authorization-server" + p, base + "/.well-known/openid-configuration" + p, base + p + "/.well-known/openid-configuration"]
415
+ : [base + "/.well-known/oauth-authorization-server", base + "/.well-known/openid-configuration"]
416
+ for (const c of cands) {
417
+ let m: Obj
418
+ try {
419
+ m = await getJSON(httpFn, c)
420
+ } catch {
421
+ continue
422
+ }
423
+ if (m.issuer !== issuer) throw new Error(`AS metadata issuer ${m.issuer} != ${issuer}`)
424
+ return m
425
+ }
426
+ throw new Error(`no authorization server metadata for ${issuer}`)
427
+ }
428
+
429
+ /** Parses `Bearer k="v", k2=v2` (RFC 6750 §3 / RFC 9110 auth-param). */
430
+ export function parseChallenge(h: string): Record<string, string> {
431
+ const out: Record<string, string> = {}
432
+ h = h.trim()
433
+ if (h.startsWith("Bearer")) h = h.slice(6)
434
+ h = h.trim()
435
+ while (h) {
436
+ const eq = h.indexOf("=")
437
+ if (eq < 0) break
438
+ const k = h.slice(0, eq).trim().toLowerCase()
439
+ h = h.slice(eq + 1).trim()
440
+ let v: string
441
+ if (h.startsWith('"')) {
442
+ let end = 1
443
+ while (end < h.length && h[end] !== '"') {
444
+ if (h[end] === "\\") end++
445
+ end++
446
+ }
447
+ v = h.slice(1, Math.min(end, h.length)).replaceAll('\\"', '"')
448
+ h = h.slice(Math.min(end + 1, h.length))
449
+ } else {
450
+ let end = h.indexOf(",")
451
+ if (end < 0) end = h.length
452
+ v = h.slice(0, end).trim()
453
+ h = h.slice(end)
454
+ }
455
+ out[k] = v
456
+ h = h.trim()
457
+ if (h.startsWith(",")) h = h.slice(1)
458
+ h = h.trim()
459
+ }
460
+ return out
461
+ }
462
+
463
+ function strings(v: unknown): string[] {
464
+ return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []
465
+ }
466
+
467
+ function formEscape(s: string): string {
468
+ return new URLSearchParams({ x: s }).toString().slice(2)
469
+ }
470
+
471
+ export function randStr(n: number): string {
472
+ return randomBytes(n).toString("base64url")
473
+ }
@@ -0,0 +1,161 @@
1
+ /**
2
+ * Stateless MCP 2026-07-28 client (SPEC §2.2–§2.7): Streamable HTTP or stdio, one API.
3
+ * Never sends `initialize`; no session; never re-sends a request except after a pre-dispatch
4
+ * 401/403 auth rejection (§2.6).
5
+ */
6
+ import { createHash } from "node:crypto"
7
+ import {
8
+ defaultHttp, encodeHeader, HttpStatusError, META_CLIENT_CAPS, META_CLIENT_INFO, META_VERSION, NotJsonRpcError,
9
+ PROTOCOL_VERSION, RpcError, DEFAULT_TIMEOUT_MS, type HttpFn, type HttpResponse, type Obj,
10
+ } from "./protocol.js"
11
+ import type { OAuth } from "./auth.js"
12
+ import { parseSkillURI } from "./skills.js"
13
+
14
+ /** The transport behind a stdio client (stdio.ts). */
15
+ export interface Transport {
16
+ roundTrip(id: number, body: string, timeoutMs: number, signal?: AbortSignal): Promise<Obj>
17
+ close(): Promise<void>
18
+ }
19
+
20
+ export interface CallOptions {
21
+ timeoutMs?: number
22
+ signal?: AbortSignal
23
+ }
24
+
25
+ export class Mcp2026Client {
26
+ readonly url: string
27
+ info: Obj = { name: "toolnexus", version: "0.1.0" }
28
+ caps: Obj = { elicitation: { form: {} } }
29
+ http: HttpFn = defaultHttp
30
+ headers: Record<string, string>
31
+ auth?: OAuth
32
+ timeoutMs: number
33
+ transport?: Transport
34
+ private nextId = 0
35
+
36
+ constructor(url: string, o: { headers?: Record<string, string>; auth?: OAuth; timeoutMs?: number; transport?: Transport } = {}) {
37
+ this.url = url
38
+ this.headers = o.headers ?? {}
39
+ this.auth = o.auth
40
+ this.timeoutMs = o.timeoutMs ?? DEFAULT_TIMEOUT_MS
41
+ this.transport = o.transport
42
+ }
43
+
44
+ /** Sends one request; params gets the per-request _meta merged in. Throws RpcError on a JSON-RPC error. */
45
+ async call(method: string, params: Obj = {}, o: CallOptions = {}): Promise<Obj> {
46
+ params = { ...params, _meta: { ...(params._meta ?? {}), [META_VERSION]: PROTOCOL_VERSION, [META_CLIENT_CAPS]: this.caps, [META_CLIENT_INFO]: this.info } }
47
+ const id = ++this.nextId
48
+ const body = JSON.stringify({ jsonrpc: "2.0", id, method, params })
49
+ const timeoutMs = o.timeoutMs ?? this.timeoutMs
50
+ const msg = this.transport ? await this.transport.roundTrip(id, body, timeoutMs, o.signal) : await this.httpRoundTrip(method, params, body, timeoutMs, o.signal)
51
+ if (msg.error != null) {
52
+ const e = msg.error
53
+ const err = new RpcError(Number(e.code), String(e.message ?? ""), e.data)
54
+ if (msg.__status) err.httpStatus = msg.__status
55
+ throw err
56
+ }
57
+ const r = msg.result
58
+ if (!r || typeof r !== "object" || Array.isArray(r)) throw new NotJsonRpcError("response has neither result nor error")
59
+ if (!("resultType" in r)) r.resultType = "complete"
60
+ if (r.resultType !== "complete" && r.resultType !== "input_required") throw new Error(`unsupported resultType "${String(r.resultType)}"`)
61
+ return r
62
+ }
63
+
64
+ private async httpRoundTrip(method: string, params: Obj, body: string, timeoutMs: number, signal?: AbortSignal): Promise<Obj> {
65
+ let resp: HttpResponse
66
+ for (let attempt = 0; ; attempt++) {
67
+ const sent = this.auth ? await this.auth.bearer(this.url) : ""
68
+ resp = await this.post(method, params, body, sent, timeoutMs, signal) // a network error is never retried
69
+ const www = resp.headers["www-authenticate"] ?? ""
70
+ if (!this.auth || (resp.status !== 401 && resp.status !== 403) || !www) break
71
+ if (attempt >= 3) throw new Error(`authorization failed: HTTP ${resp.status} after ${attempt + 1} attempts`)
72
+ await this.auth.challenge(this.url, resp.status, www, sent, () => this.call(method, params, { timeoutMs, signal }))
73
+ }
74
+ let msg: Obj | undefined
75
+ if ((resp.headers["content-type"] ?? "").startsWith("text/event-stream")) {
76
+ // The last data: payload carrying a result or error is the final response.
77
+ for (const line of resp.body.split(/\r?\n/)) {
78
+ if (!line.startsWith("data:")) continue
79
+ try {
80
+ const m = JSON.parse(line.slice(5))
81
+ if (m && (m.result !== undefined || m.error !== undefined)) msg = m
82
+ } catch {
83
+ /* not JSON: ignore */
84
+ }
85
+ }
86
+ } else {
87
+ try {
88
+ msg = JSON.parse(resp.body)
89
+ } catch {
90
+ msg = undefined
91
+ }
92
+ }
93
+ const ok = resp.status >= 200 && resp.status < 300
94
+ const isMsg = !!msg && typeof msg === "object" && (msg.result != null || (msg.error != null && typeof msg.error === "object" && typeof msg.error.code === "number"))
95
+ if (!isMsg) {
96
+ if (!ok) throw new HttpStatusError(resp.status)
97
+ throw new NotJsonRpcError(`HTTP ${resp.status}: response is not a JSON-RPC response`)
98
+ }
99
+ if (!ok && msg!.error == null) throw new HttpStatusError(resp.status)
100
+ return { ...msg, __status: ok ? undefined : resp.status }
101
+ }
102
+
103
+ private post(method: string, params: Obj, body: string, bearer: string, timeoutMs: number, signal?: AbortSignal): Promise<HttpResponse> {
104
+ const h: Record<string, string> = {
105
+ ...this.headers,
106
+ "Content-Type": "application/json",
107
+ Accept: "application/json, text/event-stream",
108
+ "MCP-Protocol-Version": PROTOCOL_VERSION,
109
+ "Mcp-Method": method,
110
+ }
111
+ if (method === "tools/call" || method === "prompts/get") h["Mcp-Name"] = encodeHeader(String(params.name ?? ""))
112
+ if (method === "resources/read") h["Mcp-Name"] = encodeHeader(String(params.uri ?? ""))
113
+ if (bearer) h.Authorization = "Bearer " + bearer
114
+ return this.http({ method: "POST", url: this.url, headers: h, body, timeoutMs, signal })
115
+ }
116
+
117
+ /** tools/list (one page — no nextCursor on the 2026 path, §2.9). Returns the tools and the ttlMs hint. */
118
+ async listTools(o: CallOptions = {}): Promise<{ tools: Obj[]; ttlMs?: number }> {
119
+ const r = await this.call("tools/list", {}, o)
120
+ const tools = (Array.isArray(r.tools) ? r.tools : []).filter((t: unknown) => t && typeof t === "object" && typeof (t as Obj).name === "string")
121
+ return { tools, ttlMs: typeof r.ttlMs === "number" ? r.ttlMs : undefined }
122
+ }
123
+
124
+ /**
125
+ * One remote skill (§2.7) as a data skill: skills/get, then resources/read of its SKILL.md (sha256
126
+ * verified). Every resource URI must be inside the skill. Returns the raw SKILL.md text and the
127
+ * other resources' relative paths.
128
+ */
129
+ async fetchSkill(uri: string, o: CallOptions = {}): Promise<{ name: string; markdown: string; resources: string[] }> {
130
+ const { name } = parseSkillURI(uri)
131
+ const r = await this.call("skills/get", { uri }, o)
132
+ const res: Obj[] = Array.isArray(r.skill?.resources) ? r.skill.resources : []
133
+ const others: string[] = []
134
+ let md: Obj | undefined
135
+ for (const m of res) {
136
+ const fu = String(m?.uri ?? "")
137
+ let rel: string
138
+ try {
139
+ const p = parseSkillURI(fu)
140
+ if (p.name !== name) throw new Error()
141
+ rel = p.rel
142
+ } catch {
143
+ throw new Error(`refusing skill resource "${fu}": outside skill ${name}`)
144
+ }
145
+ if (rel === "SKILL.md") md = m
146
+ else others.push(rel)
147
+ }
148
+ const mdUri = md ? String(md.uri) : uri
149
+ const rr = await this.call("resources/read", { uri: mdUri }, o)
150
+ const cs = Array.isArray(rr.contents) ? rr.contents : []
151
+ if (cs.length === 0) throw new Error(`${mdUri}: empty resources/read`)
152
+ const text = typeof cs[0]?.text === "string" ? cs[0].text : ""
153
+ const d = typeof md?.digest === "string" ? md.digest : ""
154
+ if (d && d !== "sha256:" + createHash("sha256").update(text).digest("hex")) throw new Error(`${mdUri}: digest mismatch`)
155
+ return { name, markdown: text, resources: others.sort() }
156
+ }
157
+
158
+ async close(): Promise<void> {
159
+ await this.transport?.close()
160
+ }
161
+ }