pi-mcp-adapter 2.2.1 → 2.3.2

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.
@@ -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
+ }
package/mcp-auth.ts ADDED
@@ -0,0 +1,277 @@
1
+ /**
2
+ * MCP Auth Storage Module
3
+ *
4
+ * Handles secure storage of OAuth credentials, tokens, client information,
5
+ * and PKCE state for MCP servers. Maintains backward compatibility with
6
+ * per-server directory structure.
7
+ *
8
+ * Token storage location: ~/.pi/agent/mcp-oauth/<server>/tokens.json
9
+ */
10
+
11
+ import { mkdirSync, readFileSync, writeFileSync, existsSync, rmSync } from 'fs';
12
+ import { join } from 'path';
13
+ import { homedir } from 'os';
14
+
15
+ /** OAuth token storage format */
16
+ export interface StoredTokens {
17
+ accessToken: string;
18
+ refreshToken?: string;
19
+ expiresAt?: number; // Unix timestamp in seconds
20
+ scope?: string;
21
+ }
22
+
23
+ /** OAuth client information from dynamic or static registration */
24
+ export interface StoredClientInfo {
25
+ clientId: string;
26
+ clientSecret?: string;
27
+ clientIdIssuedAt?: number;
28
+ clientSecretExpiresAt?: number;
29
+ }
30
+
31
+ /** Complete auth entry for a server */
32
+ export interface AuthEntry {
33
+ tokens?: StoredTokens;
34
+ clientInfo?: StoredClientInfo;
35
+ codeVerifier?: string;
36
+ oauthState?: string;
37
+ serverUrl?: string; // Track the URL these credentials are for
38
+ }
39
+
40
+ // Base directory for auth storage - can be overridden via env var for testing
41
+ function getAuthBaseDir(): string {
42
+ return process.env.MCP_OAUTH_DIR
43
+ ? process.env.MCP_OAUTH_DIR
44
+ : join(homedir(), '.pi', 'agent', 'mcp-oauth');
45
+ }
46
+
47
+ /**
48
+ * Get the server-specific directory path.
49
+ */
50
+ function getServerDir(serverName: string): string {
51
+ return join(getAuthBaseDir(), serverName);
52
+ }
53
+
54
+ /**
55
+ * Get the tokens file path for a server.
56
+ */
57
+ function getTokensFilePath(serverName: string): string {
58
+ return join(getServerDir(serverName), 'tokens.json');
59
+ }
60
+
61
+ /**
62
+ * Ensure the server directory exists with secure permissions.
63
+ */
64
+ function ensureServerDir(serverName: string): void {
65
+ const dir = getServerDir(serverName);
66
+ if (!existsSync(dir)) {
67
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
68
+ }
69
+ }
70
+
71
+ /**
72
+ * Read the auth entry for a server from disk.
73
+ * Returns undefined if file doesn't exist.
74
+ */
75
+ function readAuthEntry(serverName: string): AuthEntry | undefined {
76
+ try {
77
+ const filePath = getTokensFilePath(serverName);
78
+ if (!existsSync(filePath)) {
79
+ return undefined;
80
+ }
81
+ const data = readFileSync(filePath, 'utf-8');
82
+ return JSON.parse(data) as AuthEntry;
83
+ } catch (error) {
84
+ console.error(`Failed to read auth entry for ${serverName}:`, error);
85
+ return undefined;
86
+ }
87
+ }
88
+
89
+ /**
90
+ * Write the auth entry for a server to disk with secure permissions.
91
+ */
92
+ function writeAuthEntry(serverName: string, entry: AuthEntry): void {
93
+ ensureServerDir(serverName);
94
+ const filePath = getTokensFilePath(serverName);
95
+ writeFileSync(filePath, JSON.stringify(entry, null, 2), { mode: 0o600 });
96
+ }
97
+
98
+ /**
99
+ * Get auth entry for a server.
100
+ */
101
+ export function getAuthEntry(serverName: string): AuthEntry | undefined {
102
+ return readAuthEntry(serverName);
103
+ }
104
+
105
+ /**
106
+ * Get auth entry and validate it's for the correct URL.
107
+ * Returns undefined if URL has changed (credentials are invalid).
108
+ */
109
+ export function getAuthForUrl(serverName: string, serverUrl: string): AuthEntry | undefined {
110
+ const entry = getAuthEntry(serverName);
111
+ if (!entry) return undefined;
112
+
113
+ // If no serverUrl is stored, this is from an old version - consider it invalid
114
+ if (!entry.serverUrl) return undefined;
115
+
116
+ // If URL has changed, credentials are invalid
117
+ if (entry.serverUrl !== serverUrl) return undefined;
118
+
119
+ return entry;
120
+ }
121
+
122
+ /**
123
+ * Save auth entry for a server.
124
+ */
125
+ export function saveAuthEntry(serverName: string, entry: AuthEntry, serverUrl?: string): void {
126
+ // Always update serverUrl if provided
127
+ if (serverUrl) {
128
+ entry.serverUrl = serverUrl;
129
+ }
130
+ writeAuthEntry(serverName, entry);
131
+ }
132
+
133
+ /**
134
+ * Remove auth entry for a server.
135
+ * Also removes the server directory if empty.
136
+ */
137
+ export function removeAuthEntry(serverName: string): void {
138
+ try {
139
+ const filePath = getTokensFilePath(serverName);
140
+ if (existsSync(filePath)) {
141
+ writeFileSync(filePath, '{}', { mode: 0o600 });
142
+ }
143
+ // Try to remove the directory
144
+ const dir = getServerDir(serverName);
145
+ if (existsSync(dir)) {
146
+ try {
147
+ rmSync(dir, { recursive: true });
148
+ } catch {
149
+ // Directory may not be empty, ignore
150
+ }
151
+ }
152
+ } catch (error) {
153
+ console.error(`Failed to remove auth entry for ${serverName}:`, error);
154
+ }
155
+ }
156
+
157
+ /**
158
+ * Update tokens for a server.
159
+ */
160
+ export function updateTokens(
161
+ serverName: string,
162
+ tokens: StoredTokens,
163
+ serverUrl?: string
164
+ ): void {
165
+ const entry = getAuthEntry(serverName) ?? {};
166
+ entry.tokens = tokens;
167
+ saveAuthEntry(serverName, entry, serverUrl);
168
+ }
169
+
170
+ /**
171
+ * Update client info for a server.
172
+ */
173
+ export function updateClientInfo(
174
+ serverName: string,
175
+ clientInfo: StoredClientInfo,
176
+ serverUrl?: string
177
+ ): void {
178
+ const entry = getAuthEntry(serverName) ?? {};
179
+ entry.clientInfo = clientInfo;
180
+ saveAuthEntry(serverName, entry, serverUrl);
181
+ }
182
+
183
+ /**
184
+ * Update code verifier for a server.
185
+ */
186
+ export function updateCodeVerifier(serverName: string, codeVerifier: string): void {
187
+ const entry = getAuthEntry(serverName) ?? {};
188
+ entry.codeVerifier = codeVerifier;
189
+ saveAuthEntry(serverName, entry);
190
+ }
191
+
192
+ /**
193
+ * Clear code verifier for a server.
194
+ */
195
+ export function clearCodeVerifier(serverName: string): void {
196
+ const entry = getAuthEntry(serverName);
197
+ if (entry) {
198
+ delete entry.codeVerifier;
199
+ saveAuthEntry(serverName, entry);
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Update OAuth state for a server.
205
+ */
206
+ export function updateOAuthState(serverName: string, state: string): void {
207
+ const entry = getAuthEntry(serverName) ?? {};
208
+ entry.oauthState = state;
209
+ saveAuthEntry(serverName, entry);
210
+ }
211
+
212
+ /**
213
+ * Get OAuth state for a server.
214
+ */
215
+ export function getOAuthState(serverName: string): string | undefined {
216
+ const entry = getAuthEntry(serverName);
217
+ return entry?.oauthState;
218
+ }
219
+
220
+ /**
221
+ * Clear OAuth state for a server.
222
+ */
223
+ export function clearOAuthState(serverName: string): void {
224
+ const entry = getAuthEntry(serverName);
225
+ if (entry) {
226
+ delete entry.oauthState;
227
+ saveAuthEntry(serverName, entry);
228
+ }
229
+ }
230
+
231
+ /**
232
+ * Check if stored tokens are expired.
233
+ * Returns null if no tokens exist, false if no expiry or not expired, true if expired.
234
+ */
235
+ export function isTokenExpired(serverName: string): boolean | null {
236
+ const entry = getAuthEntry(serverName);
237
+ if (!entry?.tokens) return null;
238
+ if (!entry.tokens.expiresAt) return false;
239
+ return entry.tokens.expiresAt < Date.now() / 1000;
240
+ }
241
+
242
+ /**
243
+ * Check if a server has stored tokens.
244
+ */
245
+ export function hasStoredTokens(serverName: string): boolean {
246
+ const entry = getAuthEntry(serverName);
247
+ return !!entry?.tokens;
248
+ }
249
+
250
+ /**
251
+ * Clear all credentials for a server.
252
+ */
253
+ export function clearAllCredentials(serverName: string): void {
254
+ removeAuthEntry(serverName);
255
+ }
256
+
257
+ /**
258
+ * Clear only client info for a server.
259
+ */
260
+ export function clearClientInfo(serverName: string): void {
261
+ const entry = getAuthEntry(serverName);
262
+ if (entry) {
263
+ delete entry.clientInfo;
264
+ saveAuthEntry(serverName, entry);
265
+ }
266
+ }
267
+
268
+ /**
269
+ * Clear only tokens for a server.
270
+ */
271
+ export function clearTokens(serverName: string): void {
272
+ const entry = getAuthEntry(serverName);
273
+ if (entry) {
274
+ delete entry.tokens;
275
+ saveAuthEntry(serverName, entry);
276
+ }
277
+ }