pi-mcp-adapter 2.2.2 → 2.3.3
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/CHANGELOG.md +32 -0
- package/README.md +1 -2
- package/commands.ts +48 -20
- package/direct-tools.ts +7 -0
- package/index.ts +5 -0
- package/init.ts +20 -4
- package/mcp-auth-flow.ts +378 -0
- package/mcp-auth.ts +277 -0
- package/mcp-callback-server.ts +251 -0
- package/mcp-oauth-provider.ts +265 -0
- package/package.json +16 -8
- package/proxy-modes.ts +31 -0
- package/server-manager.ts +66 -24
- package/types.ts +27 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,38 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [2.3.3] - 2026-04-12
|
|
11
|
+
|
|
12
|
+
### Fixed
|
|
13
|
+
- Remove the blank footer status line when no MCP servers are configured by clearing the MCP status entry instead of setting it to an empty string. Thanks @HazAT for PR #27.
|
|
14
|
+
|
|
15
|
+
## [2.3.2] - 2026-04-11
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Optional `oauth.grantType: "client_credentials"` for non-interactive machine-to-machine OAuth on HTTP MCP servers.
|
|
19
|
+
|
|
20
|
+
### Fixed
|
|
21
|
+
- `/mcp-auth <server>` now handles `client_credentials` without browser/callback flow.
|
|
22
|
+
- MCP panel status no longer marks `client_credentials` servers as auth-blocked solely because no stored user tokens exist yet.
|
|
23
|
+
- OAuth auth flow now closes temporary transports consistently on success, refresh, and auth removal paths.
|
|
24
|
+
- Init paths now preserve debug-level context for previously silent direct-tool bootstrap and lazy-connect failures.
|
|
25
|
+
|
|
26
|
+
## [2.3.1] - 2026-04-11
|
|
27
|
+
|
|
28
|
+
### Fixed
|
|
29
|
+
- Removed `/mcp-auth-callback`. OAuth auth now hard-cuts to `/mcp-auth <server>` only.
|
|
30
|
+
|
|
31
|
+
## [2.3.0] - 2026-04-11
|
|
32
|
+
|
|
33
|
+
### Added
|
|
34
|
+
- OAuth callback server initialization on session start and a deprecated `/mcp-auth-callback` command that now points users to `/mcp-auth <server>`.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
- OAuth `needs-auth` handling across `/mcp` status/panel, `mcp({ connect })`, `mcp({ tool })`, reconnect flow, lazy/direct tool execution, and startup bootstrap.
|
|
38
|
+
- OAuth callback cleanup now cancels by stored OAuth state and closes pending transports on failure/cancel paths.
|
|
39
|
+
- Callback server now fails fast when the OAuth callback port is occupied by another process.
|
|
40
|
+
- Package manifest test now ignores root `*.test.ts` files.
|
|
41
|
+
|
|
10
42
|
## [2.2.2] - 2026-04-03
|
|
11
43
|
|
|
12
44
|
### Fixed
|
package/README.md
CHANGED
|
@@ -85,6 +85,7 @@ Two calls instead of 26 tools cluttering the context.
|
|
|
85
85
|
| `cwd` | Working directory |
|
|
86
86
|
| `url` | HTTP endpoint (StreamableHTTP with SSE fallback) |
|
|
87
87
|
| `auth` | `"bearer"` or `"oauth"` |
|
|
88
|
+
| `oauth.grantType` | `"authorization_code"` (default) or `"client_credentials"` for non-interactive machine auth |
|
|
88
89
|
| `bearerToken` / `bearerTokenEnv` | Token or env var name |
|
|
89
90
|
| `lifecycle` | `"lazy"` (default), `"eager"`, or `"keep-alive"` |
|
|
90
91
|
| `idleTimeout` | Minutes before idle disconnect (overrides global) |
|
|
@@ -291,6 +292,4 @@ Tool names are fuzzy-matched on hyphens and underscores — `context7_resolve_li
|
|
|
291
292
|
|
|
292
293
|
## Limitations
|
|
293
294
|
|
|
294
|
-
- OAuth tokens obtained externally (no browser flow)
|
|
295
|
-
- No automatic token refresh
|
|
296
295
|
- Cross-session server sharing not yet implemented (each Pi session runs its own server processes)
|
package/commands.ts
CHANGED
|
@@ -4,8 +4,9 @@ import type { McpConfig, ServerEntry, McpPanelCallbacks, McpPanelResult } from "
|
|
|
4
4
|
import { getServerProvenance, writeDirectToolsConfig } from "./config.js";
|
|
5
5
|
import { lazyConnect, updateMetadataCache, updateStatusBar, getFailureAgeSeconds } from "./init.js";
|
|
6
6
|
import { loadMetadataCache } from "./metadata-cache.js";
|
|
7
|
-
import { getStoredTokens } from "./oauth-handler.js";
|
|
8
7
|
import { buildToolMetadata } from "./tool-metadata.js";
|
|
8
|
+
import { supportsOAuth, authenticate } from "./mcp-auth-flow.js";
|
|
9
|
+
import { hasStoredTokens } from "./mcp-auth.js";
|
|
9
10
|
|
|
10
11
|
export async function showStatus(state: McpExtensionState, ctx: ExtensionContext): Promise<void> {
|
|
11
12
|
if (!ctx.hasUI) return;
|
|
@@ -24,6 +25,9 @@ export async function showStatus(state: McpExtensionState, ctx: ExtensionContext
|
|
|
24
25
|
if (connection?.status === "connected") {
|
|
25
26
|
status = "connected";
|
|
26
27
|
statusIcon = "✓";
|
|
28
|
+
} else if (connection?.status === "needs-auth") {
|
|
29
|
+
status = "needs auth";
|
|
30
|
+
statusIcon = "⚠";
|
|
27
31
|
} else if (failedAgo !== null) {
|
|
28
32
|
status = `failed ${failedAgo}s ago`;
|
|
29
33
|
statusIcon = "✗";
|
|
@@ -85,6 +89,12 @@ export async function reconnectServers(
|
|
|
85
89
|
await state.manager.close(name);
|
|
86
90
|
|
|
87
91
|
const connection = await state.manager.connect(name, definition);
|
|
92
|
+
if (connection.status === "needs-auth") {
|
|
93
|
+
if (ctx.hasUI) {
|
|
94
|
+
ctx.ui.notify(`MCP: ${name} requires OAuth. Run /mcp-auth ${name} first.`, "warning");
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
88
98
|
const prefix = state.config.settings?.toolPrefix ?? "server";
|
|
89
99
|
|
|
90
100
|
const { metadata, failedTools } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
|
@@ -126,10 +136,10 @@ export async function authenticateServer(
|
|
|
126
136
|
return;
|
|
127
137
|
}
|
|
128
138
|
|
|
129
|
-
if (definition
|
|
139
|
+
if (!supportsOAuth(definition)) {
|
|
130
140
|
ctx.ui.notify(
|
|
131
141
|
`Server "${serverName}" does not use OAuth authentication.\n` +
|
|
132
|
-
`
|
|
142
|
+
`Set "auth": "oauth" or omit auth for auto-detection.`,
|
|
133
143
|
"error"
|
|
134
144
|
);
|
|
135
145
|
return;
|
|
@@ -143,21 +153,31 @@ export async function authenticateServer(
|
|
|
143
153
|
return;
|
|
144
154
|
}
|
|
145
155
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
156
|
+
// Full automatic OAuth flow using SDK
|
|
157
|
+
try {
|
|
158
|
+
ctx.ui.setStatus("mcp-auth", `Authenticating ${serverName}...`);
|
|
159
|
+
|
|
160
|
+
// Runs the configured OAuth flow (interactive browser or non-interactive client_credentials)
|
|
161
|
+
const status = await authenticate(serverName, definition.url, definition);
|
|
162
|
+
|
|
163
|
+
if (status === "authenticated") {
|
|
164
|
+
ctx.ui.notify(
|
|
165
|
+
`OAuth authentication successful for "${serverName}"!\n` +
|
|
166
|
+
`Run /mcp reconnect ${serverName} to connect with the new token.`,
|
|
167
|
+
"success"
|
|
168
|
+
);
|
|
169
|
+
} else {
|
|
170
|
+
ctx.ui.notify(
|
|
171
|
+
`OAuth authentication failed for "${serverName}".`,
|
|
172
|
+
"error"
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
177
|
+
ctx.ui.notify(`Failed to authenticate "${serverName}": ${message}`, "error");
|
|
178
|
+
} finally {
|
|
179
|
+
ctx.ui.setStatus("mcp-auth", undefined);
|
|
180
|
+
}
|
|
161
181
|
}
|
|
162
182
|
|
|
163
183
|
export async function openMcpPanel(
|
|
@@ -176,10 +196,18 @@ export async function openMcpPanel(
|
|
|
176
196
|
},
|
|
177
197
|
getConnectionStatus: (serverName: string) => {
|
|
178
198
|
const definition = config.mcpServers[serverName];
|
|
179
|
-
|
|
199
|
+
const connection = state.manager.getConnection(serverName);
|
|
200
|
+
if (connection?.status === "needs-auth") {
|
|
201
|
+
return "needs-auth";
|
|
202
|
+
}
|
|
203
|
+
if (
|
|
204
|
+
definition?.auth === "oauth"
|
|
205
|
+
&& definition.oauth !== false
|
|
206
|
+
&& definition.oauth?.grantType !== "client_credentials"
|
|
207
|
+
&& !hasStoredTokens(serverName)
|
|
208
|
+
) {
|
|
180
209
|
return "needs-auth";
|
|
181
210
|
}
|
|
182
|
-
const connection = state.manager.getConnection(serverName);
|
|
183
211
|
if (connection?.status === "connected") return "connected";
|
|
184
212
|
if (getFailureAgeSeconds(state, serverName) !== null) return "failed";
|
|
185
213
|
return "idle";
|
package/direct-tools.ts
CHANGED
|
@@ -198,6 +198,13 @@ export function createDirectToolExecutor(
|
|
|
198
198
|
|
|
199
199
|
const connected = await lazyConnect(state, spec.serverName);
|
|
200
200
|
if (!connected) {
|
|
201
|
+
const authConnection = state.manager.getConnection(spec.serverName);
|
|
202
|
+
if (authConnection?.status === "needs-auth") {
|
|
203
|
+
return {
|
|
204
|
+
content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" requires OAuth authentication. Run /mcp-auth ${spec.serverName} first.` }],
|
|
205
|
+
details: { error: "auth_required", server: spec.serverName },
|
|
206
|
+
};
|
|
207
|
+
}
|
|
201
208
|
const failedAgo = getFailureAgeSeconds(state, spec.serverName);
|
|
202
209
|
return {
|
|
203
210
|
content: [{ type: "text" as const, text: `MCP server "${spec.serverName}" not available${failedAgo !== null ? ` (failed ${failedAgo}s ago)` : ""}` }],
|
package/index.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { flushMetadataCache, initializeMcp, updateStatusBar } from "./init.js";
|
|
|
8
8
|
import { loadMetadataCache } from "./metadata-cache.js";
|
|
9
9
|
import { executeCall, executeConnect, executeDescribe, executeList, executeSearch, executeStatus, executeUiMessages } from "./proxy-modes.js";
|
|
10
10
|
import { getConfigPathFromArgv, truncateAtWord } from "./utils.js";
|
|
11
|
+
import { initializeOAuth } from "./mcp-auth-flow.js";
|
|
11
12
|
|
|
12
13
|
export default function mcpAdapter(pi: ExtensionAPI) {
|
|
13
14
|
let state: McpExtensionState | null = null;
|
|
@@ -93,6 +94,10 @@ export default function mcpAdapter(pi: ExtensionAPI) {
|
|
|
93
94
|
return;
|
|
94
95
|
}
|
|
95
96
|
|
|
97
|
+
await initializeOAuth().catch(err => {
|
|
98
|
+
console.error("MCP OAuth initialization failed:", err);
|
|
99
|
+
});
|
|
100
|
+
|
|
96
101
|
const promise = initializeMcp(pi, ctx);
|
|
97
102
|
initPromise = promise;
|
|
98
103
|
|
package/init.ts
CHANGED
|
@@ -108,6 +108,9 @@ export async function initializeMcp(
|
|
|
108
108
|
const results = await parallelLimit(startupServers, 10, async ([name, definition]) => {
|
|
109
109
|
try {
|
|
110
110
|
const connection = await manager.connect(name, definition);
|
|
111
|
+
if (connection.status === "needs-auth") {
|
|
112
|
+
return { name, definition, connection: null, error: `OAuth authentication required. Run /mcp-auth ${name}.` };
|
|
113
|
+
}
|
|
111
114
|
return { name, definition, connection, error: null };
|
|
112
115
|
} catch (error) {
|
|
113
116
|
const message = error instanceof Error ? error.message : String(error);
|
|
@@ -169,11 +172,16 @@ export async function initializeMcp(
|
|
|
169
172
|
const definition = config.mcpServers[name];
|
|
170
173
|
try {
|
|
171
174
|
const connection = await manager.connect(name, definition);
|
|
175
|
+
if (connection.status === "needs-auth") {
|
|
176
|
+
return { name, ok: false };
|
|
177
|
+
}
|
|
172
178
|
const { metadata } = buildToolMetadata(connection.tools, connection.resources, definition, name, prefix);
|
|
173
179
|
toolMetadata.set(name, metadata);
|
|
174
180
|
updateMetadataCache(state, name);
|
|
175
181
|
return { name, ok: true };
|
|
176
|
-
} catch {
|
|
182
|
+
} catch (error) {
|
|
183
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
184
|
+
logger.debug(`MCP: direct-tools bootstrap failed for ${name}: ${message}`);
|
|
177
185
|
return { name, ok: false };
|
|
178
186
|
}
|
|
179
187
|
},
|
|
@@ -262,7 +270,7 @@ export function updateStatusBar(state: McpExtensionState): void {
|
|
|
262
270
|
if (!ui) return;
|
|
263
271
|
const total = Object.keys(state.config.mcpServers).length;
|
|
264
272
|
if (total === 0) {
|
|
265
|
-
ui.setStatus("mcp",
|
|
273
|
+
ui.setStatus("mcp", undefined);
|
|
266
274
|
return;
|
|
267
275
|
}
|
|
268
276
|
const connectedCount = state.manager.getAllConnections().size;
|
|
@@ -279,6 +287,9 @@ export function getFailureAgeSeconds(state: McpExtensionState, serverName: strin
|
|
|
279
287
|
|
|
280
288
|
export async function lazyConnect(state: McpExtensionState, serverName: string): Promise<boolean> {
|
|
281
289
|
const connection = state.manager.getConnection(serverName);
|
|
290
|
+
if (connection?.status === "needs-auth") {
|
|
291
|
+
return false;
|
|
292
|
+
}
|
|
282
293
|
if (connection?.status === "connected") {
|
|
283
294
|
updateServerMetadata(state, serverName);
|
|
284
295
|
return true;
|
|
@@ -294,14 +305,19 @@ export async function lazyConnect(state: McpExtensionState, serverName: string):
|
|
|
294
305
|
if (state.ui) {
|
|
295
306
|
state.ui.setStatus("mcp", `MCP: connecting to ${serverName}...`);
|
|
296
307
|
}
|
|
297
|
-
await state.manager.connect(serverName, definition);
|
|
308
|
+
const newConnection = await state.manager.connect(serverName, definition);
|
|
309
|
+
if (newConnection.status === "needs-auth") {
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
298
312
|
state.failureTracker.delete(serverName);
|
|
299
313
|
updateServerMetadata(state, serverName);
|
|
300
314
|
updateMetadataCache(state, serverName);
|
|
301
315
|
updateStatusBar(state);
|
|
302
316
|
return true;
|
|
303
|
-
} catch {
|
|
317
|
+
} catch (error) {
|
|
304
318
|
state.failureTracker.set(serverName, Date.now());
|
|
319
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
320
|
+
logger.debug(`MCP: lazy connect failed for ${serverName}: ${message}`);
|
|
305
321
|
updateStatusBar(state);
|
|
306
322
|
return false;
|
|
307
323
|
}
|
package/mcp-auth-flow.ts
ADDED
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP Auth Flow
|
|
3
|
+
*
|
|
4
|
+
* High-level OAuth flow management using the MCP SDK's built-in auth functions.
|
|
5
|
+
* Follows the OpenCode pattern: let the SDK handle discovery internally via transport.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
UnauthorizedError,
|
|
10
|
+
} from "@modelcontextprotocol/sdk/client/auth.js"
|
|
11
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"
|
|
12
|
+
import { Client } from "@modelcontextprotocol/sdk/client/index.js"
|
|
13
|
+
import open from "open"
|
|
14
|
+
import { McpOAuthProvider, type McpOAuthConfig } from "./mcp-oauth-provider.js"
|
|
15
|
+
import {
|
|
16
|
+
ensureCallbackServer,
|
|
17
|
+
waitForCallback,
|
|
18
|
+
cancelPendingCallback,
|
|
19
|
+
stopCallbackServer,
|
|
20
|
+
} from "./mcp-callback-server.js"
|
|
21
|
+
import {
|
|
22
|
+
getAuthForUrl,
|
|
23
|
+
isTokenExpired,
|
|
24
|
+
hasStoredTokens,
|
|
25
|
+
clearAllCredentials,
|
|
26
|
+
updateOAuthState,
|
|
27
|
+
getOAuthState,
|
|
28
|
+
clearOAuthState,
|
|
29
|
+
type StoredTokens,
|
|
30
|
+
} from "./mcp-auth.js"
|
|
31
|
+
import type { ServerEntry } from "./types.js"
|
|
32
|
+
|
|
33
|
+
/** Auth status for a server */
|
|
34
|
+
export type AuthStatus = "authenticated" | "expired" | "not_authenticated"
|
|
35
|
+
|
|
36
|
+
// Track pending transports for auth completion
|
|
37
|
+
const pendingTransports = new Map<string, StreamableHTTPClientTransport>()
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Generate a cryptographically secure random state parameter.
|
|
41
|
+
*/
|
|
42
|
+
function generateState(): string {
|
|
43
|
+
return Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
|
44
|
+
.map((b) => b.toString(16).padStart(2, "0"))
|
|
45
|
+
.join("")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Extract OAuth configuration from a ServerEntry.
|
|
50
|
+
*/
|
|
51
|
+
function extractOAuthConfig(definition: ServerEntry): McpOAuthConfig {
|
|
52
|
+
// If oauth is explicitly false, return empty config
|
|
53
|
+
if (definition.oauth === false) {
|
|
54
|
+
return {}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
grantType: definition.oauth?.grantType,
|
|
58
|
+
clientId: definition.oauth?.clientId,
|
|
59
|
+
clientSecret: definition.oauth?.clientSecret,
|
|
60
|
+
scope: definition.oauth?.scope,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Start OAuth authentication flow for a server.
|
|
66
|
+
* Returns the authorization URL that should be opened in a browser.
|
|
67
|
+
*
|
|
68
|
+
* This follows the OpenCode pattern:
|
|
69
|
+
* 1. Create transport with auth provider
|
|
70
|
+
* 2. Try to connect - SDK handles discovery internally
|
|
71
|
+
* 3. If UnauthorizedError, capture the auth URL from onRedirect
|
|
72
|
+
*/
|
|
73
|
+
export async function startAuth(
|
|
74
|
+
serverName: string,
|
|
75
|
+
serverUrl: string,
|
|
76
|
+
definition?: ServerEntry
|
|
77
|
+
): Promise<{ authorizationUrl: string; transport: StreamableHTTPClientTransport }> {
|
|
78
|
+
const config = definition ? extractOAuthConfig(definition) : {}
|
|
79
|
+
|
|
80
|
+
if (config.grantType === "client_credentials") {
|
|
81
|
+
const authProvider = new McpOAuthProvider(serverName, serverUrl, config, {
|
|
82
|
+
onRedirect: async () => {
|
|
83
|
+
throw new Error("Browser redirect is not used for client_credentials flow")
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
|
87
|
+
authProvider,
|
|
88
|
+
})
|
|
89
|
+
const client = new Client({
|
|
90
|
+
name: "pi-mcp",
|
|
91
|
+
version: "3.0.0",
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
await client.connect(transport)
|
|
96
|
+
return { authorizationUrl: "", transport }
|
|
97
|
+
} finally {
|
|
98
|
+
await client.close().catch(() => {})
|
|
99
|
+
await transport.close().catch(() => {})
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Start the callback server
|
|
104
|
+
await ensureCallbackServer()
|
|
105
|
+
|
|
106
|
+
// Generate and store OAuth state BEFORE creating the provider
|
|
107
|
+
// The SDK will call provider.state() to read this value
|
|
108
|
+
const oauthState = generateState()
|
|
109
|
+
await updateOAuthState(serverName, oauthState)
|
|
110
|
+
|
|
111
|
+
// Create the auth provider
|
|
112
|
+
let capturedUrl: URL | undefined
|
|
113
|
+
const authProvider = new McpOAuthProvider(serverName, serverUrl, config, {
|
|
114
|
+
onRedirect: async (url) => {
|
|
115
|
+
capturedUrl = url
|
|
116
|
+
},
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
// Create transport with auth provider
|
|
120
|
+
// The SDK handles OAuth discovery internally when connecting
|
|
121
|
+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
|
122
|
+
authProvider,
|
|
123
|
+
})
|
|
124
|
+
const client = new Client({
|
|
125
|
+
name: "pi-mcp",
|
|
126
|
+
version: "3.0.0",
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
// Try to connect - this triggers the OAuth flow
|
|
130
|
+
try {
|
|
131
|
+
await client.connect(transport)
|
|
132
|
+
// If we get here, we're already authenticated
|
|
133
|
+
await client.close().catch(() => {})
|
|
134
|
+
await transport.close().catch(() => {})
|
|
135
|
+
return { authorizationUrl: "", transport }
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error instanceof UnauthorizedError && capturedUrl) {
|
|
138
|
+
await client.close().catch(() => {})
|
|
139
|
+
// Store transport for later finishAuth
|
|
140
|
+
pendingTransports.set(serverName, transport)
|
|
141
|
+
return { authorizationUrl: capturedUrl.toString(), transport }
|
|
142
|
+
}
|
|
143
|
+
await client.close().catch(() => {})
|
|
144
|
+
await transport.close().catch(() => {})
|
|
145
|
+
throw error
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Complete OAuth authentication with the authorization code.
|
|
151
|
+
*/
|
|
152
|
+
export async function completeAuth(
|
|
153
|
+
serverName: string,
|
|
154
|
+
authorizationCode: string
|
|
155
|
+
): Promise<AuthStatus> {
|
|
156
|
+
const transport = pendingTransports.get(serverName)
|
|
157
|
+
if (!transport) {
|
|
158
|
+
throw new Error(`No pending OAuth flow for server: ${serverName}`)
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
// Complete the auth using the transport's finishAuth method
|
|
163
|
+
await transport.finishAuth(authorizationCode)
|
|
164
|
+
return "authenticated"
|
|
165
|
+
} finally {
|
|
166
|
+
pendingTransports.delete(serverName)
|
|
167
|
+
await transport.close().catch(() => {})
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Perform the complete OAuth authentication flow for a server.
|
|
173
|
+
*
|
|
174
|
+
* @param serverName - The name of the MCP server
|
|
175
|
+
* @param serverUrl - The URL of the MCP server
|
|
176
|
+
* @param definition - The server definition (optional)
|
|
177
|
+
* @returns The final auth status
|
|
178
|
+
*/
|
|
179
|
+
export async function authenticate(
|
|
180
|
+
serverName: string,
|
|
181
|
+
serverUrl: string,
|
|
182
|
+
definition?: ServerEntry,
|
|
183
|
+
): Promise<AuthStatus> {
|
|
184
|
+
// Start auth flow
|
|
185
|
+
const { authorizationUrl } = await startAuth(serverName, serverUrl, definition)
|
|
186
|
+
|
|
187
|
+
// If no auth URL needed, already authenticated
|
|
188
|
+
if (!authorizationUrl) {
|
|
189
|
+
return "authenticated"
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Get the state that was already generated and stored in startAuth()
|
|
193
|
+
const oauthState = await getOAuthState(serverName)
|
|
194
|
+
if (!oauthState) {
|
|
195
|
+
throw new Error("OAuth state not found - this should not happen")
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// Register the callback BEFORE opening the browser
|
|
199
|
+
const callbackPromise = waitForCallback(oauthState)
|
|
200
|
+
|
|
201
|
+
// Open browser
|
|
202
|
+
console.log(`MCP Auth: Opening browser for ${serverName}`)
|
|
203
|
+
try {
|
|
204
|
+
await open(authorizationUrl)
|
|
205
|
+
} catch (error) {
|
|
206
|
+
console.warn(`MCP Auth: Failed to open browser for ${serverName}`, { error })
|
|
207
|
+
throw new Error(
|
|
208
|
+
`Could not open browser. Please open this URL manually: ${authorizationUrl}`
|
|
209
|
+
)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
// Wait for callback
|
|
214
|
+
const code = await callbackPromise
|
|
215
|
+
|
|
216
|
+
// Validate state
|
|
217
|
+
const storedState = await getOAuthState(serverName)
|
|
218
|
+
if (storedState !== oauthState) {
|
|
219
|
+
await clearOAuthState(serverName)
|
|
220
|
+
throw new Error("OAuth state mismatch - potential CSRF attack")
|
|
221
|
+
}
|
|
222
|
+
await clearOAuthState(serverName)
|
|
223
|
+
|
|
224
|
+
// Complete the auth
|
|
225
|
+
return await completeAuth(serverName, code)
|
|
226
|
+
} catch (error) {
|
|
227
|
+
cancelPendingCallback(oauthState)
|
|
228
|
+
const pendingTransport = pendingTransports.get(serverName)
|
|
229
|
+
if (pendingTransport) {
|
|
230
|
+
pendingTransports.delete(serverName)
|
|
231
|
+
await pendingTransport.close().catch(() => {})
|
|
232
|
+
}
|
|
233
|
+
throw error
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get a valid access token for a server, refreshing if necessary.
|
|
239
|
+
*
|
|
240
|
+
* @param serverName - The name of the MCP server
|
|
241
|
+
* @param serverUrl - The URL of the MCP server
|
|
242
|
+
* @returns The valid tokens or null if not authenticated
|
|
243
|
+
*/
|
|
244
|
+
export async function getValidToken(
|
|
245
|
+
serverName: string,
|
|
246
|
+
serverUrl: string,
|
|
247
|
+
): Promise<StoredTokens | null> {
|
|
248
|
+
// Check if we have valid tokens
|
|
249
|
+
const entry = await getAuthForUrl(serverName, serverUrl)
|
|
250
|
+
if (!entry?.tokens) {
|
|
251
|
+
return null
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
// Check expiration
|
|
255
|
+
const expired = await isTokenExpired(serverName)
|
|
256
|
+
if (expired === false) {
|
|
257
|
+
return entry.tokens
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (expired === true && entry.tokens.refreshToken) {
|
|
261
|
+
// Token is expired, try to refresh
|
|
262
|
+
console.log(`MCP Auth: Token expired for ${serverName}, attempting refresh`)
|
|
263
|
+
|
|
264
|
+
try {
|
|
265
|
+
// Create auth provider for token refresh
|
|
266
|
+
const authProvider = new McpOAuthProvider(serverName, serverUrl, {}, {
|
|
267
|
+
onRedirect: async () => {},
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
const clientInfo = await authProvider.clientInformation()
|
|
271
|
+
if (!clientInfo) {
|
|
272
|
+
console.log(`MCP Auth: No client info for refresh for ${serverName}`)
|
|
273
|
+
return null
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Try to get tokens to find the token endpoint
|
|
277
|
+
const existingTokens = await authProvider.tokens()
|
|
278
|
+
if (!existingTokens) {
|
|
279
|
+
return null
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Create transport to trigger refresh
|
|
283
|
+
const transport = new StreamableHTTPClientTransport(new URL(serverUrl), {
|
|
284
|
+
authProvider,
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
// Try to connect - SDK will attempt token refresh internally
|
|
288
|
+
const client = new Client({ name: "pi-mcp", version: "3.0.0" })
|
|
289
|
+
try {
|
|
290
|
+
await client.connect(transport)
|
|
291
|
+
// Get refreshed tokens
|
|
292
|
+
const refreshed = await getAuthForUrl(serverName, serverUrl)
|
|
293
|
+
return refreshed?.tokens ?? null
|
|
294
|
+
} catch (error) {
|
|
295
|
+
console.error(`MCP Auth: Token refresh failed for ${serverName}`, { error })
|
|
296
|
+
return null
|
|
297
|
+
} finally {
|
|
298
|
+
await client.close().catch(() => {})
|
|
299
|
+
await transport.close().catch(() => {})
|
|
300
|
+
}
|
|
301
|
+
} catch (error) {
|
|
302
|
+
console.error(`MCP Auth: Token refresh failed for ${serverName}`, { error })
|
|
303
|
+
return null
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// No expiration info or no refresh token, assume valid
|
|
308
|
+
return entry.tokens
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Check the authentication status for a server.
|
|
313
|
+
*
|
|
314
|
+
* @param serverName - The name of the MCP server
|
|
315
|
+
* @returns The current auth status
|
|
316
|
+
*/
|
|
317
|
+
export async function getAuthStatus(serverName: string): Promise<AuthStatus> {
|
|
318
|
+
const hasTokens = await hasStoredTokens(serverName)
|
|
319
|
+
if (!hasTokens) return "not_authenticated"
|
|
320
|
+
|
|
321
|
+
const expired = await isTokenExpired(serverName)
|
|
322
|
+
return expired ? "expired" : "authenticated"
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* Remove all OAuth credentials for a server.
|
|
327
|
+
*
|
|
328
|
+
* @param serverName - The name of the MCP server
|
|
329
|
+
*/
|
|
330
|
+
export async function removeAuth(serverName: string): Promise<void> {
|
|
331
|
+
const oauthState = await getOAuthState(serverName)
|
|
332
|
+
if (oauthState) {
|
|
333
|
+
cancelPendingCallback(oauthState)
|
|
334
|
+
}
|
|
335
|
+
const pendingTransport = pendingTransports.get(serverName)
|
|
336
|
+
if (pendingTransport) {
|
|
337
|
+
pendingTransports.delete(serverName)
|
|
338
|
+
await pendingTransport.close().catch(() => {})
|
|
339
|
+
}
|
|
340
|
+
clearAllCredentials(serverName)
|
|
341
|
+
await clearOAuthState(serverName)
|
|
342
|
+
console.log(`MCP Auth: Removed credentials for ${serverName}`)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Check if OAuth is supported for a server configuration.
|
|
347
|
+
* OAuth is supported for HTTP servers unless explicitly disabled.
|
|
348
|
+
*
|
|
349
|
+
* @param definition - The server definition
|
|
350
|
+
* @returns True if OAuth is supported
|
|
351
|
+
*/
|
|
352
|
+
export function supportsOAuth(definition: ServerEntry): boolean {
|
|
353
|
+
// OAuth requires a URL
|
|
354
|
+
if (!definition.url) return false
|
|
355
|
+
|
|
356
|
+
// Explicitly disabled via auth: false or oauth: false
|
|
357
|
+
if (definition.auth === false) return false
|
|
358
|
+
if (definition.oauth === false) return false
|
|
359
|
+
|
|
360
|
+
// OAuth is enabled if auth is 'oauth' or not specified (auto-detect)
|
|
361
|
+
return definition.auth === "oauth" || definition.auth === undefined
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Initialize the OAuth system on startup.
|
|
366
|
+
* Starts the callback server if there are any OAuth servers configured.
|
|
367
|
+
*/
|
|
368
|
+
export async function initializeOAuth(): Promise<void> {
|
|
369
|
+
await ensureCallbackServer()
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Shutdown the OAuth system.
|
|
374
|
+
* Stops the callback server and cancels pending auths.
|
|
375
|
+
*/
|
|
376
|
+
export async function shutdownOAuth(): Promise<void> {
|
|
377
|
+
await stopCallbackServer()
|
|
378
|
+
}
|