pi-soly 1.9.3 → 1.11.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/ask/index.ts +12 -11
- package/ask/picker.ts +356 -76
- package/ask/prompt.ts +6 -2
- package/ask/tests/picker.test.ts +273 -82
- package/codemap.ts +276 -0
- package/hotreload.ts +239 -0
- package/init.ts +302 -0
- package/mcp/CHANGELOG.md +384 -0
- package/mcp/LICENSE +21 -0
- package/mcp/OAUTH.md +355 -0
- package/mcp/README.md +410 -0
- package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
- package/mcp/__tests__/cli.upstream-test.ts +97 -0
- package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
- package/mcp/__tests__/config.upstream-test.ts +303 -0
- package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
- package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
- package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
- package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
- package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
- package/mcp/__tests__/errors.upstream-test.ts +218 -0
- package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
- package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
- package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
- package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
- package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
- package/mcp/__tests__/logger.upstream-test.ts +175 -0
- package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
- package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
- package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
- package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
- package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
- package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
- package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
- package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
- package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
- package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
- package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
- package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
- package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
- package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
- package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
- package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
- package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
- package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
- package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
- package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
- package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
- package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
- package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
- package/mcp/agent-dir.ts +20 -0
- package/mcp/app-bridge.bundle.js +67 -0
- package/mcp/cli.js +184 -0
- package/mcp/commands.ts +422 -0
- package/mcp/config.ts +666 -0
- package/mcp/consent-manager.ts +64 -0
- package/mcp/direct-tools.ts +439 -0
- package/mcp/elicitation-handler.ts +347 -0
- package/mcp/errors.ts +219 -0
- package/mcp/glimpse-ui.ts +80 -0
- package/mcp/host-html-template.ts +427 -0
- package/mcp/index.ts +362 -0
- package/mcp/init.ts +362 -0
- package/mcp/lifecycle.ts +93 -0
- package/mcp/logger.ts +169 -0
- package/mcp/mcp-auth-flow.ts +559 -0
- package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
- package/mcp/mcp-auth.ts +302 -0
- package/mcp/mcp-auth.upstream-test.ts +373 -0
- package/mcp/mcp-callback-server.ts +372 -0
- package/mcp/mcp-callback-server.upstream-test.ts +416 -0
- package/mcp/mcp-oauth-provider.ts +369 -0
- package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
- package/mcp/mcp-panel.ts +829 -0
- package/mcp/mcp-setup-panel.ts +580 -0
- package/mcp/metadata-cache.ts +201 -0
- package/mcp/notify.ts +111 -0
- package/mcp/npx-resolver.ts +424 -0
- package/mcp/oauth-handler.ts +57 -0
- package/mcp/onboarding-state.ts +68 -0
- package/mcp/package.json +106 -0
- package/mcp/panel-keys.ts +37 -0
- package/mcp/proxy-modes.ts +949 -0
- package/mcp/resource-tools.ts +17 -0
- package/mcp/sampling-handler.ts +268 -0
- package/mcp/server-manager.ts +545 -0
- package/mcp/state.ts +41 -0
- package/mcp/tool-metadata.ts +216 -0
- package/mcp/tool-registrar.ts +46 -0
- package/mcp/tool-result-renderer.ts +161 -0
- package/mcp/types.ts +448 -0
- package/mcp/ui-resource-handler.ts +146 -0
- package/mcp/ui-server.ts +623 -0
- package/mcp/ui-session.ts +386 -0
- package/mcp/ui-stream-types.ts +89 -0
- package/mcp/utils.ts +129 -0
- package/mcp/vitest.config.ts +14 -0
- package/migrate.ts +258 -0
- package/notification.ts +218 -0
- package/notifications-log.ts +83 -0
- package/package.json +20 -3
- package/status.ts +140 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Auth Flow
|
|
3
|
+
*
|
|
4
|
+
* High-level OAuth flow management using the MCP SDK's built-in auth functions.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
auth as runSdkAuth,
|
|
9
|
+
UnauthorizedError,
|
|
10
|
+
} from "@modelcontextprotocol/sdk/client/auth.js"
|
|
11
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
12
|
+
import open from "open"
|
|
13
|
+
import { McpOAuthProvider, type McpOAuthConfig } from "./mcp-oauth-provider.ts"
|
|
14
|
+
import {
|
|
15
|
+
ensureCallbackServer,
|
|
16
|
+
waitForCallback,
|
|
17
|
+
cancelPendingCallback,
|
|
18
|
+
stopCallbackServer,
|
|
19
|
+
releaseCallbackServer,
|
|
20
|
+
} from "./mcp-callback-server.ts"
|
|
21
|
+
import {
|
|
22
|
+
getAuthForUrl,
|
|
23
|
+
isTokenExpired,
|
|
24
|
+
hasStoredTokens,
|
|
25
|
+
clearAllCredentials,
|
|
26
|
+
clearClientInfo,
|
|
27
|
+
clearTokens,
|
|
28
|
+
clearCodeVerifier,
|
|
29
|
+
updateOAuthState,
|
|
30
|
+
getOAuthState,
|
|
31
|
+
clearOAuthState,
|
|
32
|
+
type StoredTokens,
|
|
33
|
+
} from "./mcp-auth.ts"
|
|
34
|
+
import type { ServerEntry } from "./types.ts"
|
|
35
|
+
|
|
36
|
+
/** Auth status for a server */
|
|
37
|
+
export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
|
|
38
|
+
|
|
39
|
+
// Track pending transports for auth completion
|
|
40
|
+
const pendingTransports = new Map<string, StreamableHTTPClientTransport>()
|
|
41
|
+
const pendingAuthStates = new Map<string, string>()
|
|
42
|
+
const pendingAuthCleanupTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
43
|
+
|
|
44
|
+
// Deduplicate concurrent authenticate() calls per server.
|
|
45
|
+
const pendingAuthentications = new Map<string, Promise<AuthStatus>>()
|
|
46
|
+
|
|
47
|
+
/** Timeout for manual auth completion (5 minutes) */
|
|
48
|
+
const MANUAL_AUTH_TIMEOUT_MS = 5 * 60 * 1000
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Generate a cryptographically secure random state parameter.
|
|
52
|
+
*/
|
|
53
|
+
function generateState(): string {
|
|
54
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
|
55
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
56
|
+
.join("")
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Extract OAuth configuration from a ServerEntry.
|
|
61
|
+
*/
|
|
62
|
+
export function extractOAuthConfig(definition: ServerEntry): McpOAuthConfig {
|
|
63
|
+
if (definition.oauth === false) {
|
|
64
|
+
return {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const config: McpOAuthConfig = {}
|
|
68
|
+
if (definition.oauth?.grantType !== undefined) config.grantType = definition.oauth.grantType
|
|
69
|
+
if (definition.oauth?.clientId !== undefined) config.clientId = definition.oauth.clientId
|
|
70
|
+
if (definition.oauth?.clientSecret !== undefined) config.clientSecret = definition.oauth.clientSecret
|
|
71
|
+
if (definition.oauth?.scope !== undefined) config.scope = definition.oauth.scope
|
|
72
|
+
if (definition.oauth?.redirectUri !== undefined) {
|
|
73
|
+
if (typeof definition.oauth.redirectUri !== "string") {
|
|
74
|
+
throw new Error("OAuth redirectUri must be a string")
|
|
75
|
+
}
|
|
76
|
+
const redirectUri = definition.oauth.redirectUri.trim()
|
|
77
|
+
if (!redirectUri) {
|
|
78
|
+
throw new Error("OAuth redirectUri must not be empty")
|
|
79
|
+
}
|
|
80
|
+
config.redirectUri = redirectUri
|
|
81
|
+
}
|
|
82
|
+
if (definition.oauth?.clientName !== undefined) {
|
|
83
|
+
if (typeof definition.oauth.clientName !== "string") {
|
|
84
|
+
throw new Error("OAuth clientName must be a string")
|
|
85
|
+
}
|
|
86
|
+
const clientName = definition.oauth.clientName.trim()
|
|
87
|
+
if (!clientName) {
|
|
88
|
+
throw new Error("OAuth clientName must not be empty")
|
|
89
|
+
}
|
|
90
|
+
config.clientName = clientName
|
|
91
|
+
}
|
|
92
|
+
if (definition.oauth?.clientUri !== undefined) {
|
|
93
|
+
if (typeof definition.oauth.clientUri !== "string") {
|
|
94
|
+
throw new Error("OAuth clientUri must be a string")
|
|
95
|
+
}
|
|
96
|
+
const clientUri = definition.oauth.clientUri.trim()
|
|
97
|
+
if (!clientUri) {
|
|
98
|
+
throw new Error("OAuth clientUri must not be empty")
|
|
99
|
+
}
|
|
100
|
+
config.clientUri = clientUri
|
|
101
|
+
}
|
|
102
|
+
return config
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function parseOAuthRedirectUri(redirectUri: string): { port: number; callbackHost: string; callbackPath: string } {
|
|
106
|
+
let url: URL
|
|
107
|
+
try {
|
|
108
|
+
url = new URL(redirectUri)
|
|
109
|
+
} catch (error) {
|
|
110
|
+
throw new Error(`Invalid OAuth redirectUri: ${redirectUri}`, { cause: error })
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const hostname = url.hostname.toLowerCase()
|
|
114
|
+
const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"
|
|
115
|
+
if (url.protocol !== "http:" || !isLocalhost) {
|
|
116
|
+
throw new Error("OAuth redirectUri must be an http:// localhost or loopback URI")
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (url.username || url.password) {
|
|
120
|
+
throw new Error("OAuth redirectUri must not include username or password")
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (url.hash) {
|
|
124
|
+
throw new Error("OAuth redirectUri must not include a fragment")
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!url.port) {
|
|
128
|
+
throw new Error("OAuth redirectUri must include an explicit numeric port")
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const port = Number.parseInt(url.port, 10)
|
|
132
|
+
if (!Number.isInteger(port) || port <= 0 || port > 65535) {
|
|
133
|
+
throw new Error("OAuth redirectUri must include an explicit numeric port")
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const callbackHost = hostname === "[::1]" ? "::1" : hostname
|
|
137
|
+
return { port, callbackHost, callbackPath: url.pathname }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Start OAuth authentication flow for a server.
|
|
142
|
+
* Returns the authorization URL when browser authorization is required.
|
|
143
|
+
*/
|
|
144
|
+
export async function startAuth(
|
|
145
|
+
serverName: string,
|
|
146
|
+
serverUrl: string,
|
|
147
|
+
definition?: ServerEntry
|
|
148
|
+
): Promise<{ authorizationUrl: string }> {
|
|
149
|
+
const config = definition ? extractOAuthConfig(definition) : {}
|
|
150
|
+
|
|
151
|
+
if (config.grantType === "client_credentials") {
|
|
152
|
+
const storedAuth = await getAuthForUrl(serverName, serverUrl)
|
|
153
|
+
if (storedAuth?.clientInfo && !storedAuth.tokens && !config.clientId) {
|
|
154
|
+
clearClientInfo(serverName)
|
|
155
|
+
clearCodeVerifier(serverName)
|
|
156
|
+
await clearOAuthState(serverName)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const authProvider = new McpOAuthProvider(serverName, serverUrl, config, {
|
|
160
|
+
onRedirect: async () => {
|
|
161
|
+
throw new Error("Browser redirect is not used for client_credentials flow")
|
|
162
|
+
},
|
|
163
|
+
})
|
|
164
|
+
const result = await runSdkAuth(authProvider, { serverUrl })
|
|
165
|
+
if (result !== "AUTHORIZED") {
|
|
166
|
+
throw new UnauthorizedError("Failed to authorize")
|
|
167
|
+
}
|
|
168
|
+
return { authorizationUrl: "" }
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const redirectCallback = config.redirectUri !== undefined ? parseOAuthRedirectUri(config.redirectUri) : undefined
|
|
172
|
+
const oauthState = generateState()
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
await ensureCallbackServer({
|
|
176
|
+
strictPort: Boolean(config.clientId) || config.redirectUri !== undefined,
|
|
177
|
+
oauthState,
|
|
178
|
+
reserveState: true,
|
|
179
|
+
...(redirectCallback ? { port: redirectCallback.port, callbackHost: redirectCallback.callbackHost, callbackPath: redirectCallback.callbackPath } : {}),
|
|
180
|
+
})
|
|
181
|
+
} catch (error) {
|
|
182
|
+
await clearOAuthState(serverName)
|
|
183
|
+
throw error
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let capturedUrl: URL | undefined
|
|
187
|
+
const authProvider = new McpOAuthProvider(serverName, serverUrl, config, {
|
|
188
|
+
onRedirect: async (url) => {
|
|
189
|
+
capturedUrl = url
|
|
190
|
+
},
|
|
191
|
+
})
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const storedAuth = await getAuthForUrl(serverName, serverUrl)
|
|
195
|
+
if (storedAuth?.clientInfo && !config.clientId) {
|
|
196
|
+
if (!storedAuth.tokens) {
|
|
197
|
+
clearClientInfo(serverName)
|
|
198
|
+
clearCodeVerifier(serverName)
|
|
199
|
+
await clearOAuthState(serverName)
|
|
200
|
+
} else {
|
|
201
|
+
const redirectUris = storedAuth.clientInfo.redirectUris
|
|
202
|
+
if (!Array.isArray(redirectUris) || !redirectUris.includes(authProvider.redirectUrl ?? "")) {
|
|
203
|
+
clearClientInfo(serverName)
|
|
204
|
+
clearTokens(serverName)
|
|
205
|
+
clearCodeVerifier(serverName)
|
|
206
|
+
await clearOAuthState(serverName)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
await updateOAuthState(serverName, oauthState, serverUrl)
|
|
212
|
+
|
|
213
|
+
const result = await runSdkAuth(authProvider, { serverUrl })
|
|
214
|
+
if (result === "AUTHORIZED") {
|
|
215
|
+
releaseCallbackServer(oauthState)
|
|
216
|
+
await clearOAuthState(serverName)
|
|
217
|
+
return { authorizationUrl: "" }
|
|
218
|
+
}
|
|
219
|
+
if (!capturedUrl) {
|
|
220
|
+
throw new UnauthorizedError("OAuth authorization URL was not provided")
|
|
221
|
+
}
|
|
222
|
+
const pendingTransport = new StreamableHTTPClientTransport(new URL(serverUrl), { authProvider })
|
|
223
|
+
await setPendingTransport(serverName, pendingTransport, oauthState)
|
|
224
|
+
return { authorizationUrl: capturedUrl.toString() }
|
|
225
|
+
} catch (error) {
|
|
226
|
+
await clearPendingAuth(serverName, oauthState)
|
|
227
|
+
throw error
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function setPendingTransport(
|
|
232
|
+
serverName: string,
|
|
233
|
+
transport: StreamableHTTPClientTransport,
|
|
234
|
+
oauthState: string,
|
|
235
|
+
): Promise<void> {
|
|
236
|
+
await clearPendingAuth(serverName)
|
|
237
|
+
pendingTransports.set(serverName, transport)
|
|
238
|
+
pendingAuthStates.set(serverName, oauthState)
|
|
239
|
+
const cleanupTimer = setTimeout(() => {
|
|
240
|
+
void clearPendingAuth(serverName, oauthState)
|
|
241
|
+
}, MANUAL_AUTH_TIMEOUT_MS)
|
|
242
|
+
cleanupTimer.unref?.()
|
|
243
|
+
pendingAuthCleanupTimers.set(serverName, cleanupTimer)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function clearPendingAuth(serverName: string, oauthState?: string): Promise<void> {
|
|
247
|
+
const pendingState = pendingAuthStates.get(serverName)
|
|
248
|
+
if (oauthState && pendingState && pendingState !== oauthState) return
|
|
249
|
+
|
|
250
|
+
const timer = pendingAuthCleanupTimers.get(serverName)
|
|
251
|
+
if (timer) {
|
|
252
|
+
clearTimeout(timer)
|
|
253
|
+
pendingAuthCleanupTimers.delete(serverName)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const transport = pendingTransports.get(serverName)
|
|
257
|
+
pendingTransports.delete(serverName)
|
|
258
|
+
pendingAuthStates.delete(serverName)
|
|
259
|
+
const stateToRelease = pendingState ?? oauthState
|
|
260
|
+
if (stateToRelease) {
|
|
261
|
+
releaseCallbackServer(stateToRelease)
|
|
262
|
+
const storedState = await getOAuthState(serverName)
|
|
263
|
+
if (storedState === stateToRelease) {
|
|
264
|
+
await clearOAuthState(serverName)
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
if (transport) {
|
|
268
|
+
await transport.close().catch(() => {})
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function getSearchParamsFromInput(input: string): URLSearchParams | undefined {
|
|
273
|
+
try {
|
|
274
|
+
const url = new URL(input)
|
|
275
|
+
const params = new URLSearchParams(url.search)
|
|
276
|
+
if (url.hash) {
|
|
277
|
+
const hash = url.hash.startsWith("#") ? url.hash.slice(1) : url.hash
|
|
278
|
+
const hashParams = new URLSearchParams(hash)
|
|
279
|
+
for (const [key, value] of hashParams) {
|
|
280
|
+
if (!params.has(key)) params.set(key, value)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return params
|
|
284
|
+
} catch {
|
|
285
|
+
const query = input.includes("?") ? input.slice(input.indexOf("?") + 1) : input
|
|
286
|
+
const params = new URLSearchParams(query.startsWith("#") ? query.slice(1) : query)
|
|
287
|
+
return params.has("code") || params.has("state") || params.has("error") ? params : undefined
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* Extract an OAuth authorization code from either a raw code, a query string,
|
|
293
|
+
* or the full localhost redirect URL copied from the browser address bar.
|
|
294
|
+
*/
|
|
295
|
+
export function parseAuthorizationCodeInput(input: string, expectedState?: string): string {
|
|
296
|
+
const trimmed = input.trim()
|
|
297
|
+
if (!trimmed) {
|
|
298
|
+
throw new Error("Authorization code or redirect URL is required")
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const params = getSearchParamsFromInput(trimmed)
|
|
302
|
+
if (params) {
|
|
303
|
+
const error = params.get("error")
|
|
304
|
+
if (error) {
|
|
305
|
+
const description = params.get("error_description")
|
|
306
|
+
throw new Error(description ? `${error}: ${description}` : error)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const state = params.get("state")
|
|
310
|
+
if (expectedState && !state) {
|
|
311
|
+
throw new Error("OAuth state missing from redirect URL")
|
|
312
|
+
}
|
|
313
|
+
if (expectedState && state !== expectedState) {
|
|
314
|
+
throw new Error("OAuth state mismatch - potential CSRF attack")
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const code = params.get("code")
|
|
318
|
+
if (code) return code
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (/^[A-Za-z0-9._~+/=-]+$/.test(trimmed)) {
|
|
322
|
+
return trimmed
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
throw new Error("Could not find an OAuth authorization code in the provided input")
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Complete OAuth authentication from manual user input.
|
|
330
|
+
*/
|
|
331
|
+
export async function completeAuthFromInput(
|
|
332
|
+
serverName: string,
|
|
333
|
+
input: string,
|
|
334
|
+
): Promise<AuthStatus> {
|
|
335
|
+
const oauthState = await getOAuthState(serverName)
|
|
336
|
+
const code = parseAuthorizationCodeInput(input, oauthState)
|
|
337
|
+
return completeAuth(serverName, code)
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Complete OAuth authentication with the authorization code.
|
|
342
|
+
*/
|
|
343
|
+
export async function completeAuth(
|
|
344
|
+
serverName: string,
|
|
345
|
+
authorizationCode: string
|
|
346
|
+
): Promise<AuthStatus> {
|
|
347
|
+
const transport = pendingTransports.get(serverName)
|
|
348
|
+
if (!transport) {
|
|
349
|
+
throw new Error(`No pending OAuth flow for server: ${serverName}`)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const oauthState = await getOAuthState(serverName)
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
// Complete the auth using the transport's finishAuth method
|
|
356
|
+
await transport.finishAuth(authorizationCode)
|
|
357
|
+
return "authenticated"
|
|
358
|
+
} finally {
|
|
359
|
+
await clearPendingAuth(serverName, oauthState)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/**
|
|
364
|
+
* Perform the complete OAuth authentication flow for a server.
|
|
365
|
+
*
|
|
366
|
+
* @param serverName - The name of the MCP server
|
|
367
|
+
* @param serverUrl - The URL of the MCP server
|
|
368
|
+
* @param definition - The server definition (optional)
|
|
369
|
+
* @returns The final auth status
|
|
370
|
+
*/
|
|
371
|
+
export async function authenticate(
|
|
372
|
+
serverName: string,
|
|
373
|
+
serverUrl: string,
|
|
374
|
+
definition?: ServerEntry,
|
|
375
|
+
): Promise<AuthStatus> {
|
|
376
|
+
const inFlight = pendingAuthentications.get(serverName)
|
|
377
|
+
if (inFlight) {
|
|
378
|
+
return inFlight
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const operation = (async (): Promise<AuthStatus> => {
|
|
382
|
+
// Start auth flow
|
|
383
|
+
const { authorizationUrl } = await startAuth(serverName, serverUrl, definition)
|
|
384
|
+
|
|
385
|
+
// If no auth URL needed, already authenticated
|
|
386
|
+
if (!authorizationUrl) {
|
|
387
|
+
return "authenticated"
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Get the state that was already generated and stored in startAuth()
|
|
391
|
+
const oauthState = await getOAuthState(serverName)
|
|
392
|
+
if (!oauthState) {
|
|
393
|
+
throw new Error("OAuth state not found - this should not happen")
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Register the callback BEFORE opening the browser
|
|
397
|
+
const callbackPromise = waitForCallback(oauthState)
|
|
398
|
+
|
|
399
|
+
try {
|
|
400
|
+
// Open browser. Always print the URL first so remote/headless users can copy it
|
|
401
|
+
// even when the OS browser handoff is unavailable or invisible.
|
|
402
|
+
console.log(`MCP Auth: Open this URL to authenticate ${serverName}:\n${authorizationUrl}`)
|
|
403
|
+
try {
|
|
404
|
+
await open(authorizationUrl)
|
|
405
|
+
} catch (error) {
|
|
406
|
+
console.warn(`MCP Auth: Failed to open browser for ${serverName}; waiting for manual callback`, { error })
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Wait for callback
|
|
410
|
+
const code = await callbackPromise
|
|
411
|
+
|
|
412
|
+
// Validate state
|
|
413
|
+
const storedState = await getOAuthState(serverName)
|
|
414
|
+
if (storedState !== oauthState) {
|
|
415
|
+
await clearOAuthState(serverName)
|
|
416
|
+
throw new Error("OAuth state mismatch - potential CSRF attack")
|
|
417
|
+
}
|
|
418
|
+
await clearOAuthState(serverName)
|
|
419
|
+
|
|
420
|
+
// Complete the auth
|
|
421
|
+
return await completeAuth(serverName, code)
|
|
422
|
+
} catch (error) {
|
|
423
|
+
cancelPendingCallback(oauthState)
|
|
424
|
+
await clearPendingAuth(serverName, oauthState)
|
|
425
|
+
throw error
|
|
426
|
+
}
|
|
427
|
+
})()
|
|
428
|
+
|
|
429
|
+
pendingAuthentications.set(serverName, operation)
|
|
430
|
+
|
|
431
|
+
try {
|
|
432
|
+
return await operation
|
|
433
|
+
} finally {
|
|
434
|
+
if (pendingAuthentications.get(serverName) === operation) {
|
|
435
|
+
pendingAuthentications.delete(serverName)
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* Get a valid access token for a server, refreshing if necessary.
|
|
442
|
+
*
|
|
443
|
+
* @param serverName - The name of the MCP server
|
|
444
|
+
* @param serverUrl - The URL of the MCP server
|
|
445
|
+
* @returns The valid tokens or null if not authenticated
|
|
446
|
+
*/
|
|
447
|
+
export async function getValidToken(
|
|
448
|
+
serverName: string,
|
|
449
|
+
serverUrl: string,
|
|
450
|
+
): Promise<StoredTokens | null> {
|
|
451
|
+
// Check if we have valid tokens
|
|
452
|
+
const entry = await getAuthForUrl(serverName, serverUrl)
|
|
453
|
+
if (!entry?.tokens) {
|
|
454
|
+
return null
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// Check expiration
|
|
458
|
+
const expired = await isTokenExpired(serverName)
|
|
459
|
+
if (expired === false) {
|
|
460
|
+
return entry.tokens
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
if (expired === true && entry.tokens.refreshToken) {
|
|
464
|
+
// Token is expired, try to refresh
|
|
465
|
+
console.log(`MCP Auth: Token expired for ${serverName}, attempting refresh`)
|
|
466
|
+
|
|
467
|
+
try {
|
|
468
|
+
// Create auth provider for token refresh
|
|
469
|
+
const authProvider = new McpOAuthProvider(serverName, serverUrl, {}, {
|
|
470
|
+
onRedirect: async () => {},
|
|
471
|
+
})
|
|
472
|
+
|
|
473
|
+
const clientInfo = await authProvider.clientInformation()
|
|
474
|
+
if (!clientInfo) {
|
|
475
|
+
console.log(`MCP Auth: No client info for refresh for ${serverName}`)
|
|
476
|
+
return null
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const result = await runSdkAuth(authProvider, { serverUrl })
|
|
480
|
+
if (result !== "AUTHORIZED") {
|
|
481
|
+
return null
|
|
482
|
+
}
|
|
483
|
+
const refreshed = await getAuthForUrl(serverName, serverUrl)
|
|
484
|
+
return refreshed?.tokens ?? null
|
|
485
|
+
} catch (error) {
|
|
486
|
+
console.error(`MCP Auth: Token refresh failed for ${serverName}`, { error })
|
|
487
|
+
return null
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// No expiration info or no refresh token, assume valid
|
|
492
|
+
return entry.tokens
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
/**
|
|
496
|
+
* Check the authentication status for a server.
|
|
497
|
+
*
|
|
498
|
+
* @param serverName - The name of the MCP server
|
|
499
|
+
* @returns The current auth status
|
|
500
|
+
*/
|
|
501
|
+
export async function getAuthStatus(serverName: string): Promise<AuthStatus> {
|
|
502
|
+
const hasTokens = await hasStoredTokens(serverName)
|
|
503
|
+
if (!hasTokens) return "not_authenticated"
|
|
504
|
+
|
|
505
|
+
const expired = await isTokenExpired(serverName)
|
|
506
|
+
return expired ? "expired" : "authenticated"
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* Remove all OAuth credentials for a server.
|
|
511
|
+
*
|
|
512
|
+
* @param serverName - The name of the MCP server
|
|
513
|
+
*/
|
|
514
|
+
export async function removeAuth(serverName: string): Promise<void> {
|
|
515
|
+
const oauthState = await getOAuthState(serverName)
|
|
516
|
+
if (oauthState) {
|
|
517
|
+
cancelPendingCallback(oauthState)
|
|
518
|
+
}
|
|
519
|
+
await clearPendingAuth(serverName, oauthState)
|
|
520
|
+
clearAllCredentials(serverName)
|
|
521
|
+
await clearOAuthState(serverName)
|
|
522
|
+
console.log(`MCP Auth: Removed credentials for ${serverName}`)
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Check if OAuth is supported for a server configuration.
|
|
527
|
+
* OAuth is supported for HTTP servers unless explicitly disabled.
|
|
528
|
+
*
|
|
529
|
+
* @param definition - The server definition
|
|
530
|
+
* @returns True if OAuth is supported
|
|
531
|
+
*/
|
|
532
|
+
export function supportsOAuth(definition: ServerEntry): boolean {
|
|
533
|
+
// OAuth requires a URL
|
|
534
|
+
if (!definition.url) return false
|
|
535
|
+
|
|
536
|
+
// Explicitly disabled via auth: false or oauth: false
|
|
537
|
+
if (definition.auth === false) return false
|
|
538
|
+
if (definition.oauth === false) return false
|
|
539
|
+
|
|
540
|
+
// OAuth is enabled if auth is 'oauth' or not specified (auto-detect)
|
|
541
|
+
return definition.auth === "oauth" || definition.auth === undefined
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Initialize the OAuth system on startup.
|
|
546
|
+
* OAuth callback binding is lazy and starts from startAuth() only.
|
|
547
|
+
*/
|
|
548
|
+
export async function initializeOAuth(): Promise<void> {}
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* Shutdown the OAuth system.
|
|
552
|
+
* Stops the callback server and cancels pending auths.
|
|
553
|
+
*/
|
|
554
|
+
export async function shutdownOAuth(): Promise<void> {
|
|
555
|
+
for (const serverName of Array.from(pendingTransports.keys())) {
|
|
556
|
+
await clearPendingAuth(serverName)
|
|
557
|
+
}
|
|
558
|
+
await stopCallbackServer()
|
|
559
|
+
}
|