@fitchmultz/pi-mcp-adapter 6.0.0 → 7.1.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 (111) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/OAUTH.md +1 -1
  3. package/README.md +27 -18
  4. package/dist/app-bridge.bundle.js +14 -14
  5. package/dist/config.js +41 -16
  6. package/dist/direct-tools.js +1 -1
  7. package/dist/gateway-arguments.d.ts +1 -1
  8. package/dist/index.js +38 -59
  9. package/dist/lifecycle.d.ts +2 -2
  10. package/dist/lifecycle.js +2 -2
  11. package/dist/mcp-auth-flow.js +2 -2
  12. package/dist/mcp-auth.d.ts +1 -1
  13. package/dist/mcp-auth.js +3 -3
  14. package/dist/mcp-code.d.ts +6 -6
  15. package/dist/mcp-code.js +11 -10
  16. package/dist/mcp-oauth-provider.d.ts +5 -5
  17. package/dist/mcp-oauth-provider.js +5 -5
  18. package/dist/mcp-output-guard.d.ts +1 -1
  19. package/dist/mcp-output-guard.js +1 -1
  20. package/dist/mcp-panel.d.ts +2 -2
  21. package/dist/mcp-panel.js +2 -2
  22. package/dist/mcp-setup-panel.d.ts +5 -4
  23. package/dist/mcp-setup-panel.js +15 -7
  24. package/dist/mcp-trace.d.ts +1 -1
  25. package/dist/mcp-trace.js +1 -1
  26. package/dist/npx-resolver.js +8 -4
  27. package/dist/proxy-modes.d.ts +2 -2
  28. package/dist/proxy-modes.js +20 -19
  29. package/dist/sampling-handler.js +1 -0
  30. package/dist/search-ranking.js +6 -2
  31. package/dist/server-manager.d.ts +2 -2
  32. package/dist/server-manager.js +44 -27
  33. package/dist/session-recovery.d.ts +7 -4
  34. package/dist/session-recovery.js +76 -10
  35. package/dist/tool-loader.d.ts +7 -2
  36. package/dist/tool-loader.js +56 -5
  37. package/dist/tool-metadata.d.ts +43 -43
  38. package/dist/tool-result-renderer.d.ts +3 -3
  39. package/dist/tool-result-renderer.js +3 -3
  40. package/dist/ui-resource-handler.d.ts +2 -2
  41. package/dist/ui-resource-handler.js +2 -2
  42. package/dist/ui-server.js +5 -4
  43. package/dist/ui-stream-types.d.ts +8 -8
  44. package/dist/unix-socket-transport.d.ts +1 -1
  45. package/dist/unix-socket-transport.js +1 -1
  46. package/dist/utils.d.ts +1 -0
  47. package/dist/utils.js +14 -0
  48. package/package.json +34 -98
  49. package/skills/mcp-scripting/SKILL.md +2 -0
  50. package/abort.ts +0 -34
  51. package/agent-dir.ts +0 -24
  52. package/app-bridge.bundle.js +0 -109
  53. package/banner.png +0 -0
  54. package/checkpoint.ts +0 -55
  55. package/commands.ts +0 -622
  56. package/config.ts +0 -1204
  57. package/consent-manager.ts +0 -25
  58. package/direct-tools.ts +0 -75
  59. package/elicitation-handler.ts +0 -349
  60. package/error-signal.ts +0 -21
  61. package/gateway-arguments.ts +0 -73
  62. package/glimpse-ui.ts +0 -80
  63. package/host-html-template.ts +0 -456
  64. package/index.ts +0 -767
  65. package/init.ts +0 -613
  66. package/legacy-migration.ts +0 -47
  67. package/lifecycle.ts +0 -165
  68. package/logger.ts +0 -127
  69. package/mcp-auth-flow.ts +0 -975
  70. package/mcp-auth.ts +0 -793
  71. package/mcp-callback-server.ts +0 -450
  72. package/mcp-code.ts +0 -412
  73. package/mcp-keyring-helper.cjs +0 -89
  74. package/mcp-oauth-provider.ts +0 -774
  75. package/mcp-output-guard.ts +0 -530
  76. package/mcp-panel.ts +0 -907
  77. package/mcp-script-worker.mjs +0 -131
  78. package/mcp-setup-panel.ts +0 -666
  79. package/mcp-status.ts +0 -110
  80. package/mcp-trace.ts +0 -295
  81. package/metadata-cache.ts +0 -271
  82. package/npx-resolver.ts +0 -488
  83. package/onboarding-state.ts +0 -58
  84. package/panel-keys.ts +0 -37
  85. package/prompts.ts +0 -337
  86. package/proxy-modes.ts +0 -1109
  87. package/resource-tools.ts +0 -52
  88. package/runtime-owner.ts +0 -123
  89. package/sampling-handler.ts +0 -276
  90. package/scripts/build-app-bridge.mjs +0 -15
  91. package/scripts/build.mjs +0 -132
  92. package/scripts/prepare.mjs +0 -103
  93. package/search-ranking.ts +0 -132
  94. package/server-manager.ts +0 -1320
  95. package/session-recovery.ts +0 -316
  96. package/state.ts +0 -64
  97. package/tool-approval.ts +0 -78
  98. package/tool-loader.ts +0 -153
  99. package/tool-metadata.ts +0 -240
  100. package/tool-registrar.ts +0 -74
  101. package/tool-result-renderer.ts +0 -294
  102. package/tsconfig.build.json +0 -13
  103. package/tsconfig.json +0 -16
  104. package/types.ts +0 -785
  105. package/ui-resource-handler.ts +0 -249
  106. package/ui-server.ts +0 -808
  107. package/ui-session.ts +0 -568
  108. package/ui-stream-types.ts +0 -89
  109. package/ui-tool-visibility.ts +0 -25
  110. package/unix-socket-transport.ts +0 -85
  111. package/utils.ts +0 -307
@@ -1,774 +0,0 @@
1
- /**
2
- * MCP OAuth Provider
3
- *
4
- * Implementation of the MCP SDK's OAuthClientProvider interface.
5
- * Handles OAuth client registration, token storage, and authorization redirection.
6
- */
7
-
8
- import { AsyncLocalStorage } from "node:async_hooks"
9
- import {
10
- UnauthorizedError,
11
- OAuthError,
12
- OAuthErrorCode,
13
- createPrivateKeyJwtAuth,
14
- discoverAndRequestJwtAuthGrant,
15
- type FetchLike,
16
- validateClientMetadataUrl,
17
- type AddClientAuthentication,
18
- type OAuthClientProvider,
19
- type OAuthClientInformationContext,
20
- type StoredOAuthClientInformation,
21
- type StoredOAuthTokens,
22
- type OAuthDiscoveryState,
23
- } from "@modelcontextprotocol/client"
24
- import type {
25
- OAuthClientMetadata,
26
- } from "@modelcontextprotocol/client"
27
- import {
28
- getAuthForUrl,
29
- updateTokens,
30
- updateClientInfo,
31
- clearAllCredentials,
32
- clearClientInfo,
33
- clearCodeVerifier,
34
- clearTokens,
35
- type AuthEntry,
36
- type AuthStorageOptions,
37
- type StoredTokens,
38
- type StoredClientInfo,
39
- } from "./mcp-auth.ts"
40
- import { resolveCommandSecret } from "./utils.ts"
41
- import { isNonInteractiveOAuth, type OAuthPrivateKeyJwtConfig, type OAuthCrossAppAccessConfig } from "./types.ts"
42
- import { combineAbortSignals } from "./runtime-owner.ts"
43
- import sharedClientMetadata from "./docs/client-metadata.json" with { type: "json" }
44
-
45
- /** Validate explicit document URLs without echoing possible credentials in errors. */
46
- export function validateOAuthClientMetadataUrl(value: string | false | undefined): void {
47
- if (value === undefined || value === false) return
48
- try {
49
- if (typeof value !== "string" || !value) throw new Error()
50
- validateClientMetadataUrl(value)
51
- const url = new URL(value)
52
- const raw = value.match(/^[^:]+:[/\\]*([^/\\?#]*)([^?#]*)/)
53
- if (url.username || url.password || raw?.[1]?.includes("@") || value.includes("#")
54
- || /[/\\](?:\.|%2e){1,2}(?=[/\\]|$)/i.test(raw?.[2] ?? "")) throw new Error()
55
- } catch {
56
- throw new Error("OAuth clientMetadataUrl must be an HTTPS URL with a non-root path and no userinfo, fragment or dot segments")
57
- }
58
- }
59
-
60
- /** Only the port may differ; keep raw host, path and query spelling exact. */
61
- export function loopbackRedirectsMatch(first: string, second: unknown): boolean {
62
- if (typeof second !== "string") return false
63
- const port = /^([^:/?#]+:\/\/[^/\\?#@]+):\d+(?=[/\\?#]|$)/
64
- if (!port.test(first) || !port.test(second) || first.includes("#") || second.includes("#")) return false
65
- try {
66
- return [new URL(first), new URL(second)].every(url => url.protocol === "http:"
67
- && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname) && Number(url.port) > 0)
68
- && first.replace(port, "$1") === second.replace(port, "$1")
69
- } catch {
70
- return false
71
- }
72
- }
73
-
74
- export function issuersMatch(first: string, second: string): boolean {
75
- return first === second
76
- || (first.endsWith("/") && first.slice(0, -1) === second)
77
- || (second.endsWith("/") && second.slice(0, -1) === first)
78
- }
79
-
80
- // Callback server configuration
81
- const DEFAULT_OAUTH_CALLBACK_PORT = 19876
82
- const DEFAULT_OAUTH_CALLBACK_PATH = "/callback"
83
-
84
- let configuredOAuthCallbackPort = DEFAULT_OAUTH_CALLBACK_PORT
85
-
86
- if (process.env.MCP_OAUTH_CALLBACK_PORT) {
87
- const parsedPort = Number.parseInt(process.env.MCP_OAUTH_CALLBACK_PORT, 10)
88
- if (Number.isInteger(parsedPort) && parsedPort > 0 && parsedPort <= 65535) {
89
- configuredOAuthCallbackPort = parsedPort
90
- }
91
- }
92
-
93
- let oauthCallbackPort = configuredOAuthCallbackPort
94
- let oauthCallbackPath = DEFAULT_OAUTH_CALLBACK_PATH
95
-
96
- export function getConfiguredOAuthCallbackPort(): number {
97
- return configuredOAuthCallbackPort
98
- }
99
-
100
- export function getOAuthCallbackPort(): number {
101
- return oauthCallbackPort
102
- }
103
-
104
- export function setOAuthCallbackPort(port: number): void {
105
- oauthCallbackPort = port
106
- }
107
-
108
- export function getOAuthCallbackPath(): string {
109
- return oauthCallbackPath
110
- }
111
-
112
- export function setOAuthCallbackPath(path: string): void {
113
- oauthCallbackPath = path.startsWith("/") ? path : `/${path}`
114
- }
115
-
116
- /** Configuration options for OAuth */
117
- export interface McpOAuthConfig {
118
- skipIssuerMetadataValidation?: boolean
119
- grantType?: "authorization_code" | "client_credentials"
120
- clientId?: string
121
- clientSecret?: string
122
- privateKeyJwt?: OAuthPrivateKeyJwtConfig
123
- crossAppAccess?: OAuthCrossAppAccessConfig
124
- scope?: string
125
- authorizationParams?: Record<string, string>
126
- redirectUri?: string
127
- clientName?: string
128
- clientUri?: string
129
- /** Custom client document URL; false disables automatic CIMD for new registrations. */
130
- clientMetadataUrl?: string | false
131
- }
132
-
133
- function isRecord(value: unknown): value is Record<string, unknown> {
134
- return value !== null && typeof value === "object" && !Array.isArray(value)
135
- }
136
-
137
- /** Validate shapes and method conflicts without resolving or disclosing key sources. */
138
- export function validateOAuthPrivateKeyJwt(config: McpOAuthConfig): void {
139
- const jwt = config.privateKeyJwt
140
- if (jwt === undefined) return
141
- if (!isRecord(jwt)) throw new Error("OAuth privateKeyJwt must be an object")
142
- if (!(typeof jwt.privateKey === "string" ? jwt.privateKey.trim() : isRecord(jwt.privateKey))) {
143
- throw new Error("OAuth privateKeyJwt.privateKey must be a nonempty string or JWK object")
144
- }
145
- if (typeof jwt.algorithm !== "string" || !jwt.algorithm.trim() || /^(?:HS|none$)/i.test(jwt.algorithm)) {
146
- throw new Error("OAuth privateKeyJwt.algorithm must be an asymmetric JOSE algorithm")
147
- }
148
- if (jwt.audience !== undefined && (typeof jwt.audience !== "string" || !jwt.audience.trim())) {
149
- throw new Error("OAuth privateKeyJwt.audience must be a nonempty string")
150
- }
151
- if (jwt.lifetimeSeconds !== undefined && (!Number.isSafeInteger(jwt.lifetimeSeconds) || jwt.lifetimeSeconds <= 0)) {
152
- throw new Error("OAuth privateKeyJwt.lifetimeSeconds must be a positive integer")
153
- }
154
- if (jwt.claims !== undefined && !isRecord(jwt.claims)) throw new Error("OAuth privateKeyJwt.claims must be an object")
155
- if (config.clientSecret !== undefined) throw new Error("OAuth privateKeyJwt cannot be combined with clientSecret")
156
- if (!config.clientId && (typeof config.clientMetadataUrl !== "string" || config.clientMetadataUrl === sharedClientMetadata.client_id)) {
157
- throw new Error("OAuth privateKeyJwt requires a configured clientId or custom clientMetadataUrl, not the shared browser identity")
158
- }
159
- }
160
-
161
- export function validateOAuthCrossAppAccess(config: McpOAuthConfig): void {
162
- const crossApp = config.crossAppAccess
163
- if (crossApp === undefined) return
164
- if (!isRecord(crossApp)) throw new Error("OAuth crossAppAccess must be an object")
165
- for (const field of ["idpUrl", "clientId", "idToken", "clientSecret"] as const) {
166
- if (field === "clientSecret" && crossApp[field] === undefined) continue
167
- if (typeof crossApp[field] !== "string" || !crossApp[field].trim()) {
168
- throw new Error(`OAuth crossAppAccess.${field} must be a nonempty string`)
169
- }
170
- }
171
- if (config.grantType !== undefined) throw new Error("OAuth crossAppAccess selects the JWT-bearer grant; omit grantType")
172
- if ("audience" in crossApp || "resource" in crossApp) throw new Error("OAuth crossAppAccess uses the discovered MCP issuer and resource; overrides are not supported")
173
- }
174
-
175
- const reservedAuthorizationParams = new Set([
176
- "client_id",
177
- "code_challenge",
178
- "code_challenge_method",
179
- "redirect_uri",
180
- "resource",
181
- "response_type",
182
- "scope",
183
- "state",
184
- ])
185
-
186
- function addAuthorizationParams(authorizationUrl: URL, params: Record<string, string> | undefined): URL {
187
- if (!params) return authorizationUrl
188
- const nextUrl = new URL(authorizationUrl.toString())
189
- for (const [key, value] of Object.entries(params)) {
190
- if (reservedAuthorizationParams.has(key) || nextUrl.searchParams.has(key)) {
191
- throw new Error(`OAuth authorizationParams.${key} cannot override an authorization flow parameter`)
192
- }
193
- nextUrl.searchParams.set(key, value)
194
- }
195
- return nextUrl
196
- }
197
-
198
- /** Callbacks for OAuth flow interactions */
199
- export interface McpOAuthCallbacks {
200
- onRedirect: (url: URL) => void | Promise<void>
201
- onDiscoveryState?: (state: OAuthDiscoveryState) => void
202
- }
203
-
204
- /**
205
- * OAuth provider implementation for MCP servers.
206
- * Implements the OAuthClientProvider interface from the MCP SDK.
207
- */
208
- export class McpOAuthProvider implements OAuthClientProvider {
209
- private readonly redirectUrlSnapshot: string | undefined
210
- readonly clientMetadataUrl?: string
211
- private readonly controller = new AbortController()
212
- readonly lifetimeSignal: AbortSignal
213
- private readonly requestSignals = new AsyncLocalStorage<AbortSignal | undefined>()
214
- private flowAuthorizationServerUrl: string | undefined
215
- private flowResourceUrl: string | undefined
216
- private flowClientInfo: StoredClientInfo | undefined
217
- private flowCodeVerifier: string | undefined
218
- private flowDiscoveryState: OAuthDiscoveryState | undefined
219
- private flowIssuerMismatch = false
220
- private flowState: string | undefined
221
-
222
- constructor(
223
- private serverName: string,
224
- private serverUrl: string,
225
- private config: McpOAuthConfig,
226
- private callbacks: McpOAuthCallbacks,
227
- private storageOptions: AuthStorageOptions = {},
228
- runtimeSignal?: AbortSignal,
229
- initialState?: string,
230
- ) {
231
- validateOAuthClientMetadataUrl(config.clientMetadataUrl)
232
- validateOAuthPrivateKeyJwt(config)
233
- validateOAuthCrossAppAccess(config)
234
- this.lifetimeSignal = combineAbortSignals(runtimeSignal, this.controller.signal)!
235
- this.flowState = initialState
236
- this.redirectUrlSnapshot = isNonInteractiveOAuth(config)
237
- ? undefined
238
- : config.redirectUri ?? `http://localhost:${getOAuthCallbackPort()}${getOAuthCallbackPath()}`
239
- if (config.clientMetadataUrl !== false && config.clientSecret === undefined) {
240
- if (config.clientMetadataUrl && config.clientMetadataUrl !== sharedClientMetadata.client_id) {
241
- this.clientMetadataUrl = config.clientMetadataUrl
242
- } else if (!this.isNonInteractive && !config.privateKeyJwt
243
- && (config.clientName === undefined || config.clientName === sharedClientMetadata.client_name)
244
- && (config.clientUri === undefined || config.clientUri === sharedClientMetadata.client_uri)
245
- && sharedClientMetadata.redirect_uris.some(uri => loopbackRedirectsMatch(uri, this.redirectUrl))) {
246
- this.clientMetadataUrl = sharedClientMetadata.client_id
247
- }
248
- }
249
- }
250
-
251
- private get isNonInteractive(): boolean {
252
- return isNonInteractiveOAuth(this.config)
253
- }
254
-
255
- private get discoveredIssuer(): string | undefined {
256
- return this.flowDiscoveryState?.authorizationServerMetadata?.issuer
257
- ?? this.flowDiscoveryState?.authorizationServerUrl
258
- }
259
-
260
- get signal(): AbortSignal {
261
- return combineAbortSignals(this.lifetimeSignal, this.requestSignals.getStore())!
262
- }
263
-
264
- runWithSignal<T>(signal: AbortSignal | undefined, operation: () => T): T {
265
- this.lifetimeSignal.throwIfAborted()
266
- return this.requestSignals.run(combineAbortSignals(this.requestSignals.getStore(), signal), operation)
267
- }
268
-
269
- deactivate(): void {
270
- this.controller.abort(new Error("OAuth flow is no longer active"))
271
- this.requestSignals.disable()
272
- }
273
-
274
- private assertStoredIssuerBindings(entry: AuthEntry | undefined, issuer: string | undefined): void {
275
- if (this.flowIssuerMismatch) {
276
- throw new Error(
277
- `OAuth authorization server issuer changed for ${this.serverName}; clear credentials before authenticating again`,
278
- )
279
- }
280
- if (!entry || !issuer) return
281
-
282
- const storedIssuers = [entry.clientInfo?.issuer, entry.tokens?.issuer]
283
- .filter((storedIssuer): storedIssuer is string => storedIssuer !== undefined)
284
- if (storedIssuers.some(storedIssuer => !issuersMatch(storedIssuer, issuer))) {
285
- this.flowIssuerMismatch = true
286
- throw new Error(
287
- `OAuth authorization server issuer changed for ${this.serverName}; clear credentials before authenticating again`,
288
- )
289
- }
290
- }
291
-
292
- private throwIfInactive(): void {
293
- this.signal.throwIfAborted()
294
- }
295
-
296
- /**
297
- * The redirect URL for OAuth callbacks.
298
- * This must match the redirect_uri in client metadata.
299
- */
300
- get redirectUrl(): string | undefined {
301
- return this.redirectUrlSnapshot
302
- }
303
-
304
- /**
305
- * Client metadata for dynamic registration.
306
- * Describes this client to the OAuth authorization server.
307
- */
308
- get clientMetadata(): OAuthClientMetadata {
309
- if (this.isNonInteractive) {
310
- return {
311
- client_name: this.config.clientName ?? "Pi Coding Agent",
312
- client_uri: this.config.clientUri ?? "https://github.com/fitchmultz/pi-mcp-adapter",
313
- redirect_uris: [],
314
- grant_types: [this.config.crossAppAccess ? "urn:ietf:params:oauth:grant-type:jwt-bearer" : "client_credentials"],
315
- token_endpoint_auth_method: this.config.privateKeyJwt ? "private_key_jwt" : this.config.clientSecret ? "client_secret_post" : "none",
316
- }
317
- }
318
-
319
- const redirectUrl = this.redirectUrl
320
- if (!redirectUrl) {
321
- throw new Error("redirectUrl is required for authorization_code flow")
322
- }
323
-
324
- return {
325
- redirect_uris: [redirectUrl],
326
- client_name: this.config.clientName ?? "Pi Coding Agent",
327
- client_uri: this.config.clientUri ?? "https://github.com/fitchmultz/pi-mcp-adapter",
328
- grant_types: ["authorization_code", "refresh_token"],
329
- response_types: ["code"],
330
- token_endpoint_auth_method: this.config.privateKeyJwt ? "private_key_jwt" : this.config.clientSecret ? "client_secret_post" : "none",
331
- ...(this.config.scope !== undefined ? { scope: this.config.scope } : {}),
332
- }
333
- }
334
-
335
- /**
336
- * Get client information (for pre-registered or dynamically registered clients).
337
- * Returns undefined if no client info exists or if the server URL has changed.
338
- */
339
- async clientInformation(context?: OAuthClientInformationContext): Promise<StoredOAuthClientInformation | undefined> {
340
- const issuer = context?.issuer ?? this.discoveredIssuer
341
- const stored = await getAuthForUrl(this.serverName, this.serverUrl, this.storageOptions)
342
- this.assertStoredIssuerBindings(stored, issuer)
343
- // Rejected discovery must not become the runtime's issuer binding.
344
- if (this.flowDiscoveryState) this.callbacks.onDiscoveryState?.(this.flowDiscoveryState)
345
-
346
- // Check config first (pre-registered client). Store only its issuer binding.
347
- // The configured secret stays in config and never enters the credential store.
348
- if (this.config.clientId) {
349
- const storedClient = stored?.clientInfo?.clientId === this.config.clientId
350
- ? stored.clientInfo
351
- : undefined
352
- if (issuer && (storedClient?.issuer !== issuer || storedClient.configPreRegistered !== true)) {
353
- updateClientInfo(
354
- this.serverName,
355
- { clientId: this.config.clientId, issuer, configPreRegistered: true },
356
- this.serverUrl,
357
- this.storageOptions,
358
- )
359
- }
360
- const clientSecret = this.config.clientSecret?.startsWith("!")
361
- ? resolveCommandSecret(
362
- this.config.clientSecret,
363
- `MCP server "${this.serverName}" OAuth clientSecret`,
364
- )
365
- : this.config.clientSecret
366
- return {
367
- client_id: this.config.clientId,
368
- client_secret: clientSecret,
369
- ...(issuer !== undefined ? { issuer } : {}),
370
- }
371
- }
372
-
373
- // Keep client registration associated with this in-flight flow even if
374
- // another runtime writes the shared persistent entry for the same name.
375
- const clientInfo = this.flowClientInfo ?? stored?.clientInfo
376
- if (clientInfo) {
377
- // A stored SEP-2352 issuer stub for a config-pre-registered client
378
- // (identified by the explicit marker, or by the legacy stub shape of
379
- // {clientId, issuer} with no registration metadata) is only meaningful
380
- // when the config supplies the matching client secret. Since we reach
381
- // this branch only when config.clientId is absent, serving the stub
382
- // would let a token refresh go out with a client_id but no secret,
383
- // causing invalid_client and credential invalidation. Return undefined
384
- // so callers treat this as "no client info".
385
- const isConfigStub = clientInfo.configPreRegistered === true
386
- || (clientInfo.clientSecret === undefined
387
- && clientInfo.clientIdIssuedAt === undefined
388
- && clientInfo.clientSecretExpiresAt === undefined
389
- && clientInfo.redirectUris === undefined)
390
- if (isConfigStub) {
391
- return undefined
392
- }
393
- if ((this.config.privateKeyJwt || this.isNonInteractive) && clientInfo.registrationType === "cimd" && clientInfo.clientId === sharedClientMetadata.client_id) {
394
- throw new OAuthError(OAuthErrorCode.InvalidRequest, `OAuth ${this.config.privateKeyJwt ? "privateKeyJwt" : this.config.crossAppAccess ? "crossAppAccess" : "client_credentials"} cannot use the saved shared browser registration; configure its own clientId or replace that login`)
395
- }
396
- // Check if client secret has expired
397
- if (clientInfo.clientSecretExpiresAt && clientInfo.clientSecretExpiresAt < Date.now() / 1000) {
398
- return undefined
399
- }
400
- if (issuer && clientInfo.issuer && !issuersMatch(clientInfo.issuer, issuer)) {
401
- return undefined
402
- }
403
- if (issuer && clientInfo.issuer === undefined) {
404
- clientInfo.issuer = issuer
405
- this.flowClientInfo = clientInfo
406
- updateClientInfo(this.serverName, clientInfo, this.serverUrl, this.storageOptions)
407
- }
408
- // Return all registration metadata and the local issuer extension.
409
- // Keep the SDK's view and the stored issuer binding consistent.
410
- return {
411
- client_id: clientInfo.clientId,
412
- client_secret: clientInfo.clientSecret,
413
- ...(clientInfo.clientIdIssuedAt !== undefined
414
- ? { client_id_issued_at: clientInfo.clientIdIssuedAt }
415
- : {}),
416
- ...(clientInfo.clientSecretExpiresAt !== undefined
417
- ? { client_secret_expires_at: clientInfo.clientSecretExpiresAt }
418
- : {}),
419
- ...(clientInfo.redirectUris !== undefined
420
- ? { redirect_uris: clientInfo.redirectUris }
421
- : {}),
422
- ...(clientInfo.issuer !== undefined ? { issuer: clientInfo.issuer } : {}),
423
- }
424
- }
425
-
426
- // No client info or URL changed - will trigger dynamic registration
427
- return undefined
428
- }
429
-
430
- /**
431
- * Save client information from dynamic registration.
432
- */
433
- async saveClientInformation(info: StoredOAuthClientInformation, context?: OAuthClientInformationContext): Promise<void> {
434
- this.throwIfInactive()
435
- const issuer = context?.issuer ?? this.discoveredIssuer ?? info.issuer
436
- if (this.config.clientId && info.client_id === this.config.clientId) {
437
- updateClientInfo(
438
- this.serverName,
439
- {
440
- clientId: info.client_id,
441
- ...(issuer !== undefined ? { issuer } : {}),
442
- configPreRegistered: true,
443
- },
444
- this.serverUrl,
445
- this.storageOptions,
446
- )
447
- return
448
- }
449
-
450
- const redirectUris = ("redirect_uris" in info ? info.redirect_uris : undefined)
451
- ?? (this.redirectUrl ? [this.redirectUrl] : [])
452
- const clientInfo: StoredClientInfo = {
453
- clientId: info.client_id,
454
- ...(info.client_secret !== undefined ? { clientSecret: info.client_secret } : {}),
455
- ...(info.client_id_issued_at !== undefined ? { clientIdIssuedAt: info.client_id_issued_at } : {}),
456
- ...(info.client_secret_expires_at !== undefined ? { clientSecretExpiresAt: info.client_secret_expires_at } : {}),
457
- redirectUris,
458
- // Native CIMD saves only client_id + issuer; DCR's full schema requires redirect_uris.
459
- ...(info.client_id === this.clientMetadataUrl && !("redirect_uris" in info)
460
- && this.flowDiscoveryState?.authorizationServerMetadata?.client_id_metadata_document_supported === true
461
- ? { registrationType: "cimd" as const } : {}),
462
- ...(issuer !== undefined ? { issuer } : {}),
463
- }
464
- this.flowClientInfo = clientInfo
465
- updateClientInfo(this.serverName, clientInfo, this.serverUrl, this.storageOptions)
466
- }
467
-
468
- /**
469
- * Get stored OAuth tokens.
470
- * Returns undefined if no tokens exist or if the server URL has changed.
471
- */
472
- async tokens(context?: OAuthClientInformationContext): Promise<StoredOAuthTokens | undefined> {
473
- // Use getAuthForUrl to validate tokens are for the current server URL.
474
- const entry = await getAuthForUrl(this.serverName, this.serverUrl, this.storageOptions)
475
- if (!entry?.tokens) return undefined
476
- const issuer = context?.issuer ?? this.discoveredIssuer
477
- this.assertStoredIssuerBindings(entry, issuer)
478
- if (issuer && entry.tokens.issuer === undefined) {
479
- entry.tokens.issuer = issuer
480
- updateTokens(this.serverName, entry.tokens, this.serverUrl, this.storageOptions)
481
- }
482
-
483
- return {
484
- access_token: entry.tokens.accessToken,
485
- token_type: "Bearer",
486
- refresh_token: entry.tokens.refreshToken,
487
- expires_in: entry.tokens.expiresAt
488
- ? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000))
489
- : undefined,
490
- scope: entry.tokens.scope,
491
- ...(entry.tokens.issuer !== undefined ? { issuer: entry.tokens.issuer } : {}),
492
- }
493
- }
494
-
495
- /**
496
- * Save OAuth tokens.
497
- */
498
- async saveTokens(tokens: StoredOAuthTokens, context?: OAuthClientInformationContext): Promise<void> {
499
- const issuer = context?.issuer ?? this.discoveredIssuer ?? tokens.issuer
500
- const storedTokens: StoredTokens = {
501
- accessToken: tokens.access_token,
502
- ...(tokens.refresh_token !== undefined ? { refreshToken: tokens.refresh_token } : {}),
503
- // Preserve expiry even when expires_in is 0 (e.g. the SDK re-saving an
504
- // already-expired token) so expired tokens stay expired instead of
505
- // being persisted as never-expiring.
506
- ...(tokens.expires_in !== undefined ? { expiresAt: Date.now() / 1000 + tokens.expires_in } : {}),
507
- ...(tokens.scope !== undefined ? { scope: tokens.scope } : {}),
508
- ...(issuer !== undefined ? { issuer } : {}),
509
- }
510
- this.throwIfInactive()
511
- updateTokens(this.serverName, storedTokens, this.serverUrl, this.storageOptions)
512
- // Discovery must survive the browser redirect so the callback can verify
513
- // the authorization server that minted the code. Once token issuance
514
- // succeeds, clear it so a later 401 re-reads PRM and can observe an
515
- // authorization-server migration.
516
- this.flowDiscoveryState = undefined
517
- this.flowAuthorizationServerUrl = undefined
518
- this.flowResourceUrl = undefined
519
- }
520
-
521
- /**
522
- * Redirect the user to the authorization URL.
523
- * This opens the browser for the user to authenticate.
524
- *
525
- * Throws UnauthorizedError when called outside of a user-initiated flow
526
- * (no oauthState saved by startAuth). That path is reached when the SDK
527
- * falls through from a failed refresh into a fresh authorization_code
528
- * flow, which library hosts cannot complete in-process.
529
- */
530
- async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
531
- if (this.isNonInteractive) {
532
- throw new Error("redirectToAuthorization is not used for noninteractive OAuth flows")
533
- }
534
- // No flow-local state means we're on the post-refresh authorize fallback.
535
- this.throwIfInactive()
536
- if (!this.flowState) {
537
- throw new UnauthorizedError(
538
- `Re-authentication required for MCP server: ${this.serverName}`,
539
- )
540
- }
541
- // URL is passed to callback, not logged (may contain sensitive params)
542
- await this.callbacks.onRedirect(addAuthorizationParams(authorizationUrl, this.config.authorizationParams))
543
- }
544
-
545
- /**
546
- * Save the PKCE code verifier.
547
- */
548
- async saveCodeVerifier(codeVerifier: string): Promise<void> {
549
- this.throwIfInactive()
550
- this.flowCodeVerifier = codeVerifier
551
- }
552
-
553
- /**
554
- * Get the stored PKCE code verifier.
555
- * @throws Error if no code verifier is stored
556
- */
557
- async codeVerifier(): Promise<string> {
558
- if (this.isNonInteractive) {
559
- throw new Error("codeVerifier is not used for noninteractive OAuth flows")
560
- }
561
- this.throwIfInactive()
562
- if (!this.flowCodeVerifier) {
563
- throw new Error(`No code verifier saved for MCP server: ${this.serverName}`)
564
- }
565
- return this.flowCodeVerifier
566
- }
567
-
568
- /**
569
- * Keep discovery with the in-flight PKCE verifier. The callback leg uses it
570
- * to validate the authorization response issuer before token exchange.
571
- */
572
- async saveDiscoveryState(state: OAuthDiscoveryState): Promise<void> {
573
- this.throwIfInactive()
574
- this.flowDiscoveryState = structuredClone(state)
575
- }
576
-
577
- // Native auth calls this on every attempt, before stored issuer checks and resource selection.
578
- saveAuthorizationServerUrl(issuer: string): void {
579
- this.throwIfInactive()
580
- this.flowAuthorizationServerUrl = issuer
581
- this.flowResourceUrl = undefined
582
- }
583
-
584
- saveResourceUrl(resource: string): void {
585
- this.throwIfInactive()
586
- this.flowResourceUrl = resource
587
- }
588
-
589
- readonly fetch: FetchLike = (input, init) => fetch(input, {
590
- ...init, signal: combineAbortSignals(init?.signal ?? undefined, this.signal)!,
591
- })
592
-
593
- async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
594
- this.throwIfInactive()
595
- return this.flowDiscoveryState ? structuredClone(this.flowDiscoveryState) : undefined
596
- }
597
-
598
- /**
599
- * Save the OAuth state parameter for CSRF protection.
600
- */
601
- async saveState(state: string): Promise<void> {
602
- this.throwIfInactive()
603
- this.flowState = state
604
- }
605
-
606
- /**
607
- * Get the stored OAuth state parameter.
608
- * @throws UnauthorizedError if no flow is in progress (see redirectToAuthorization)
609
- */
610
- async state(): Promise<string> {
611
- if (this.isNonInteractive) {
612
- throw new Error("state is not used for noninteractive OAuth flows")
613
- }
614
- this.throwIfInactive()
615
- if (!this.flowState) {
616
- throw new UnauthorizedError(
617
- `Re-authentication required for MCP server: ${this.serverName}`,
618
- )
619
- }
620
- return this.flowState
621
- }
622
-
623
- /**
624
- * Invalidate credentials when authentication fails.
625
- * Clears tokens, client info, or all credentials based on the type.
626
- */
627
- async invalidateCredentials(type: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
628
- this.throwIfInactive()
629
- switch (type) {
630
- case "all":
631
- this.flowClientInfo = undefined
632
- this.flowCodeVerifier = undefined
633
- this.flowDiscoveryState = undefined
634
- this.flowAuthorizationServerUrl = undefined
635
- this.flowResourceUrl = undefined
636
- this.flowIssuerMismatch = false
637
- this.flowState = undefined
638
- clearAllCredentials(this.serverName, this.storageOptions)
639
- break
640
- case "client":
641
- this.flowClientInfo = undefined
642
- clearClientInfo(this.serverName, this.storageOptions)
643
- break
644
- case "tokens":
645
- clearTokens(this.serverName, this.storageOptions)
646
- break
647
- case "verifier":
648
- clearCodeVerifier(this.serverName, this.storageOptions)
649
- break
650
- case "discovery":
651
- this.flowDiscoveryState = undefined
652
- this.flowAuthorizationServerUrl = undefined
653
- this.flowResourceUrl = undefined
654
- break
655
- }
656
- }
657
-
658
- /**
659
- * Adds configured scope and client authentication to native token requests.
660
- */
661
- addClientAuthentication: AddClientAuthentication = async (headers, params, url, metadata) => {
662
- this.throwIfInactive()
663
- if (params.get("grant_type") === "authorization_code" && !params.has("scope") && this.config.scope) {
664
- params.set("scope", this.config.scope)
665
- }
666
-
667
- const clientInfo = await this.clientInformation(this.config.privateKeyJwt && metadata?.issuer ? { issuer: metadata.issuer } : undefined)
668
- this.throwIfInactive()
669
- if (!clientInfo) {
670
- return
671
- }
672
-
673
- const jwt = this.config.privateKeyJwt
674
- if (jwt) {
675
- if (clientInfo.client_secret !== undefined) {
676
- throw new OAuthError(OAuthErrorCode.InvalidRequest, "OAuth privateKeyJwt cannot use a shared-secret client")
677
- }
678
- let privateKey = jwt.privateKey
679
- if (typeof privateKey === "string") {
680
- try {
681
- privateKey = resolveCommandSecret(privateKey, "OAuth privateKeyJwt.privateKey")
682
- } catch (error) {
683
- // This primitive's errors contain only the outcome, never command text or output.
684
- throw new OAuthError(OAuthErrorCode.InvalidRequest, (error as Error).message)
685
- }
686
- }
687
- try {
688
- if (typeof privateKey === "string" && privateKey.trimStart().startsWith("{")) privateKey = JSON.parse(privateKey)
689
- const { algorithm, ...options } = jwt
690
- await createPrivateKeyJwtAuth({
691
- ...options, privateKey, alg: algorithm, issuer: clientInfo.client_id, subject: clientInfo.client_id,
692
- })(headers, params, url, metadata)
693
- } catch {
694
- // Ordinary errors fall through into browser consent on native refresh; invalid_client erases credentials.
695
- throw new OAuthError(OAuthErrorCode.InvalidRequest, "OAuth privateKeyJwt could not sign an assertion; check the private key and algorithm")
696
- }
697
- this.throwIfInactive()
698
- params.set("client_id", clientInfo.client_id)
699
- return
700
- }
701
-
702
- const supportedMethods = metadata?.token_endpoint_auth_methods_supported ?? []
703
- const hasClientSecret = clientInfo.client_secret !== undefined
704
- let authMethod: "client_secret_basic" | "client_secret_post" | "none"
705
-
706
- if (supportedMethods.length === 0) {
707
- authMethod = hasClientSecret ? "client_secret_post" : "none"
708
- } else if (hasClientSecret && supportedMethods.includes("client_secret_basic")) {
709
- authMethod = "client_secret_basic"
710
- } else if (hasClientSecret && supportedMethods.includes("client_secret_post")) {
711
- authMethod = "client_secret_post"
712
- } else if (supportedMethods.includes("none")) {
713
- authMethod = "none"
714
- } else {
715
- authMethod = hasClientSecret ? "client_secret_post" : "none"
716
- }
717
-
718
- if (authMethod === "client_secret_basic") {
719
- if (!clientInfo.client_secret) {
720
- throw new Error("client_secret_basic authentication requires a client_secret")
721
- }
722
- headers.set("Authorization", `Basic ${Buffer.from(`${clientInfo.client_id}:${clientInfo.client_secret}`).toString("base64")}`)
723
- return
724
- }
725
-
726
- if (!params.has("client_id")) {
727
- params.set("client_id", clientInfo.client_id)
728
- }
729
- if (authMethod === "client_secret_post" && clientInfo.client_secret && !params.has("client_secret")) {
730
- params.set("client_secret", clientInfo.client_secret)
731
- }
732
- }
733
-
734
- private async prepareCrossAppRequest(scope?: string): Promise<URLSearchParams> {
735
- this.throwIfInactive()
736
- const audience = this.flowAuthorizationServerUrl
737
- const resource = this.flowResourceUrl
738
- if (!audience || !resource) throw new OAuthError(OAuthErrorCode.InvalidRequest, "OAuth crossAppAccess requires discovered MCP authorization server and protected resource metadata")
739
- const config = this.config.crossAppAccess!
740
- try {
741
- const result = await discoverAndRequestJwtAuthGrant({
742
- idpUrl: resolveCommandSecret(config.idpUrl, "OAuth crossAppAccess.idpUrl"),
743
- clientId: resolveCommandSecret(config.clientId, "OAuth crossAppAccess.clientId"),
744
- idToken: resolveCommandSecret(config.idToken, "OAuth crossAppAccess.idToken"),
745
- ...(config.clientSecret !== undefined ? { clientSecret: resolveCommandSecret(config.clientSecret, "OAuth crossAppAccess.clientSecret") } : {}),
746
- audience, resource, ...(scope ? { scope } : {}), fetchFn: this.fetch,
747
- })
748
- this.throwIfInactive()
749
- const params = new URLSearchParams({ grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer", assertion: result.jwtAuthGrant })
750
- if (scope) params.set("scope", scope)
751
- return params
752
- } catch {
753
- this.throwIfInactive()
754
- // IdP errors must not erase the MCP login or expose token-bearing responses/commands.
755
- throw new OAuthError(OAuthErrorCode.InvalidRequest, "OAuth crossAppAccess could not obtain an IdP grant; check the IdP configuration and ID-token source")
756
- }
757
- }
758
-
759
- prepareTokenRequest(scope?: string): URLSearchParams | Promise<URLSearchParams> | undefined {
760
- if (this.config.crossAppAccess) return this.prepareCrossAppRequest(scope ?? this.config.scope)
761
- if (!this.isNonInteractive) {
762
- return undefined
763
- }
764
-
765
- const params = new URLSearchParams({ grant_type: "client_credentials" })
766
- const requestedScope = scope ?? this.config.scope
767
- if (requestedScope) {
768
- params.set("scope", requestedScope)
769
- }
770
- return params
771
- }
772
- }
773
-
774
- export { DEFAULT_OAUTH_CALLBACK_PORT, DEFAULT_OAUTH_CALLBACK_PATH }