toolnexus 0.23.0 → 0.25.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +139 -1
- package/dist/acp.d.ts +37 -6
- package/dist/acp.js +239 -52
- package/dist/agents/loop.js +4 -1
- package/dist/agents/runtime.js +4 -1
- package/dist/client.d.ts +55 -9
- package/dist/client.js +154 -41
- package/dist/index.d.ts +4 -0
- package/dist/index.js +4 -0
- 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/media.d.ts +126 -0
- package/dist/media.js +540 -0
- package/dist/skill.d.ts +21 -0
- package/dist/skill.js +44 -0
- package/dist/toolconcurrency.d.ts +16 -0
- package/dist/toolconcurrency.js +59 -0
- package/dist/toolkit.d.ts +23 -1
- package/dist/toolkit.js +26 -4
- package/dist/types.d.ts +26 -1
- package/package.json +5 -3
- package/src/acp.ts +258 -55
- package/src/agents/loop.ts +4 -1
- package/src/agents/runtime.ts +4 -1
- package/src/client.ts +196 -48
- package/src/index.ts +4 -0
- 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/media.ts +604 -0
- package/src/skill.ts +50 -0
- package/src/toolconcurrency.ts +67 -0
- package/src/toolkit.ts +35 -5
- package/src/types.ts +27 -1
|
@@ -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
|
+
}
|