pi-mcp-extension 1.3.0 → 1.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,11 @@
1
- # pi-mcp
1
+ # pi-mcp-extension
2
2
 
3
- [![npm version](https://img.shields.io/npm/v/pi-mcp.svg)](https://www.npmjs.com/package/pi-mcp)
4
- [![license](https://img.shields.io/npm/l/pi-mcp.svg)](https://github.com/irahardianto/pi-mcp/blob/main/LICENSE)
3
+ [![npm version](https://img.shields.io/npm/v/pi-mcp-extension.svg)](https://www.npmjs.com/package/pi-mcp-extension)
4
+ [![license](https://img.shields.io/npm/l/pi-mcp-extension.svg)](https://github.com/irahardianto/pi-mcp-extension/blob/main/LICENSE)
5
5
 
6
6
  **Connect [Pi](https://pi.dev) to any MCP server.** Supabase, DeepSource, Playwright, Context7, filesystem, databases — if it speaks [MCP](https://modelcontextprotocol.io), Pi can use it.
7
7
 
8
- `pi-mcp` is a production-ready [Model Context Protocol](https://modelcontextprotocol.io/) client extension for the [Pi coding agent](https://pi.dev). It manages server connections, discovers tools, and bridges them into Pi so the LLM can call them directly.
8
+ `pi-mcp-extension` is a production-ready [Model Context Protocol](https://modelcontextprotocol.io/) client extension for the [Pi coding agent](https://pi.dev). It manages server connections, discovers tools, and bridges them into Pi so the LLM can call them directly.
9
9
 
10
10
  ## Features
11
11
 
@@ -25,20 +25,20 @@
25
25
  ## Installation
26
26
 
27
27
  ```bash
28
- pi install npm:pi-mcp
28
+ pi install npm:pi-mcp-extension
29
29
  ```
30
30
 
31
31
  Or try without installing:
32
32
 
33
33
  ```bash
34
- pi -e npm:pi-mcp
34
+ pi -e npm:pi-mcp-extension
35
35
  ```
36
36
 
37
37
  ## Quick Start
38
38
 
39
39
  1. Install the extension:
40
40
  ```bash
41
- pi install npm:pi-mcp
41
+ pi install npm:pi-mcp-extension
42
42
  ```
43
43
 
44
44
  2. Create a config file at `~/.pi/agent/mcp.json` (global) or `.pi/mcp.json` (project-level):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-extension",
3
- "version": "1.3.0",
3
+ "version": "1.4.1",
4
4
  "description": "MCP (Model Context Protocol) client extension for the Pi coding agent — connect Pi to any MCP server",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -16,11 +16,11 @@
16
16
  "license": "MIT",
17
17
  "repository": {
18
18
  "type": "git",
19
- "url": "git+https://github.com/irahardianto/pi-mcp.git"
19
+ "url": "git+https://github.com/irahardianto/pi-mcp-extension.git"
20
20
  },
21
- "homepage": "https://github.com/irahardianto/pi-mcp",
21
+ "homepage": "https://github.com/irahardianto/pi-mcp-extension",
22
22
  "bugs": {
23
- "url": "https://github.com/irahardianto/pi-mcp/issues"
23
+ "url": "https://github.com/irahardianto/pi-mcp-extension/issues"
24
24
  },
25
25
  "files": [
26
26
  "src/",
package/src/config.ts CHANGED
@@ -17,6 +17,28 @@ import { McpError } from "./errors.js";
17
17
 
18
18
  // ─── Zod Schemas ──────────────────────────────────────────────────────────────
19
19
 
20
+ const AuthConfigSchema = z.object({
21
+ /** Auth type. Currently only "oauth" is supported. Default: "oauth". */
22
+ type: z.enum(["oauth"]).default("oauth"),
23
+ /**
24
+ * Callback URL for the OAuth redirect.
25
+ * Default: auto-detected local callback server.
26
+ */
27
+ redirectUrl: z.string().optional(),
28
+ /**
29
+ * Optional scope to request during authorization.
30
+ */
31
+ scope: z.string().optional(),
32
+ /**
33
+ * Pre-registered client_id (skip dynamic client registration).
34
+ */
35
+ clientId: z.string().optional(),
36
+ /**
37
+ * Pre-registered client_secret.
38
+ */
39
+ clientSecret: z.string().optional(),
40
+ });
41
+
20
42
  const ServerConfigSchema = z
21
43
  .object({
22
44
  /** Executable to spawn (e.g. "npx", "node", "uvx"). Required for stdio. */
@@ -36,6 +58,19 @@ const ServerConfigSchema = z
36
58
  * Must be a valid URL (e.g. "https://my-mcp-server.example.com/mcp").
37
59
  */
38
60
  url: z.string().url().optional(),
61
+ /**
62
+ * Static HTTP headers to include with every request (streamable-http / sse only).
63
+ * Useful for API-key-based auth (e.g. { "Authorization": "Bearer <key>" }).
64
+ * For OAuth2, use the "auth" field instead.
65
+ */
66
+ headers: z.record(z.string()).optional(),
67
+ /**
68
+ * OAuth2 configuration for servers that require authorization.
69
+ * When set, the transport will use the SDK's OAuth flow (discovery,
70
+ * dynamic client registration, PKCE, token refresh).
71
+ * Only applies to streamable-http and sse transports.
72
+ */
73
+ auth: AuthConfigSchema.optional(),
39
74
  /**
40
75
  * "eager" — start at session_start.
41
76
  * "lazy" — start manually via /mcp:start command.
@@ -85,6 +120,7 @@ const McpConfigSchema = z.object({
85
120
 
86
121
  // ─── Public Types ─────────────────────────────────────────────────────────────
87
122
 
123
+ export type AuthConfig = z.output<typeof AuthConfigSchema>;
88
124
  export type ServerConfig = z.output<typeof ServerConfigSchema>;
89
125
  export type Settings = z.output<typeof SettingsSchema>;
90
126
  export type McpConfig = z.output<typeof McpConfigSchema>;
package/src/index.ts CHANGED
@@ -10,8 +10,28 @@
10
10
  import type { ExtensionAPI, ExtensionContext, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
11
11
  import { loadConfig } from "./config.js";
12
12
  import { ServerManager } from "./server-manager.js";
13
+ import type { TransportAuthCallbacks } from "./server-manager.js";
13
14
  import { ToolBridge } from "./tool-bridge.js";
14
15
  import { McpError } from "./errors.js";
16
+ import { exec } from "node:child_process";
17
+ import { promisify } from "node:util";
18
+
19
+ const execAsync = promisify(exec);
20
+
21
+ /**
22
+ * Open a URL in the user's default browser.
23
+ * Works on macOS, Linux, and Windows.
24
+ */
25
+ function openBrowser(url: string): void {
26
+ const cmd = process.platform === "darwin"
27
+ ? `open "${url}"`
28
+ : process.platform === "win32"
29
+ ? `start "" "${url}"`
30
+ : `xdg-open "${url}"`;
31
+ exec(cmd, (err) => {
32
+ if (err) console.error(`[pi-mcp] Failed to open browser: ${err.message}`);
33
+ });
34
+ }
15
35
 
16
36
  export default async function (pi: ExtensionAPI): Promise<void> {
17
37
  // ── 1. Load and validate config ──────────────────────────────────────────
@@ -35,7 +55,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
35
55
  }
36
56
 
37
57
  // ── 2. Initialize bridge components ──────────────────────────────────────
38
- const manager = new ServerManager(config);
58
+ // Auth callbacks — opens browser and notifies user when OAuth is needed
59
+ const authCallbacks: TransportAuthCallbacks = {
60
+ onAuthRequired: (serverName: string, authorizationUrl: URL): void => {
61
+ console.error(
62
+ `[pi-mcp] OAuth required for "${serverName}". Opening browser for authorization...`,
63
+ );
64
+ openBrowser(authorizationUrl.toString());
65
+ },
66
+ };
67
+
68
+ const manager = new ServerManager(config, authCallbacks);
39
69
  const bridge = new ToolBridge(config.settings, pi);
40
70
 
41
71
  // Connect tool refresh callback: called on connect and on list_changed
@@ -167,4 +197,82 @@ export default async function (pi: ExtensionAPI): Promise<void> {
167
197
  }
168
198
  },
169
199
  });
200
+
201
+ // ── 7. /mcp:auth — trigger OAuth authentication for a server ────────────────
202
+ pi.registerCommand("mcp:auth", {
203
+ description:
204
+ "Trigger OAuth authentication for a server. Resets credentials and opens browser for re-authorization. Usage: /mcp:auth <server-name>",
205
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
206
+ const serverName = args.trim();
207
+ if (!serverName) {
208
+ // List servers with auth config
209
+ const authServers = manager.getAllServers().filter((s) => s.config.auth);
210
+ if (authServers.length === 0) {
211
+ ctx.ui.notify(
212
+ "pi-mcp: No servers with OAuth configured. Add `auth: { type: \"oauth\" }` to a server in mcp.json.",
213
+ "error",
214
+ );
215
+ return;
216
+ }
217
+ const lines = authServers.map(async (s) => {
218
+ const status = await manager.getServerAuthStatus(s.name);
219
+ const authIcon = status?.hasTokens ? "\u2705 authenticated" : "\u274C not authenticated";
220
+ const savedInfo = status?.savedAt ? ` (since ${status.savedAt})` : "";
221
+ return ` ${s.name}: ${authIcon}${savedInfo}`;
222
+ });
223
+ const statusLines = await Promise.all(lines);
224
+ ctx.ui.notify(
225
+ [
226
+ "Usage: /mcp:auth <server-name>",
227
+ "",
228
+ "OAuth-enabled servers:",
229
+ ...statusLines,
230
+ ].join("\n"),
231
+ "info",
232
+ );
233
+ return;
234
+ }
235
+ const server = manager.getServer(serverName);
236
+ if (!server) {
237
+ ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error");
238
+ return;
239
+ }
240
+ if (!server.config.auth) {
241
+ ctx.ui.notify(
242
+ `pi-mcp: Server "${serverName}" does not have OAuth configured. Add \`auth: { type: "oauth" }\` to its config in mcp.json.`,
243
+ "error",
244
+ );
245
+ return;
246
+ }
247
+
248
+ try {
249
+ // Stop the server if running
250
+ if (server.state !== "stopped") {
251
+ bridge.deactivateServer(serverName);
252
+ await manager.stopServer(serverName);
253
+ }
254
+ // Reset all OAuth credentials (tokens, client info, PKCE, discovery)
255
+ await manager.resetServerAuth(serverName);
256
+ ctx.ui.notify(
257
+ `pi-mcp: Reset credentials for ${serverName}. Starting server — a browser window will open for authentication...`,
258
+ "info",
259
+ );
260
+ // Start the server — this triggers the OAuth flow and opens the browser
261
+ await manager.startServer(serverName, ctx.cwd);
262
+
263
+ const newStatus = await manager.getServerAuthStatus(serverName);
264
+ if (newStatus?.hasTokens) {
265
+ ctx.ui.notify(`pi-mcp: ${serverName} authenticated successfully!`, "info");
266
+ } else {
267
+ ctx.ui.notify(
268
+ `pi-mcp: ${serverName} started but authentication may still be in progress. Check the browser window.`,
269
+ "info",
270
+ );
271
+ }
272
+ } catch (err) {
273
+ const msg = err instanceof McpError ? err.userMessage : String(err);
274
+ ctx.ui.notify(`pi-mcp: Authentication failed for ${serverName} — ${msg}`, "error");
275
+ }
276
+ },
277
+ });
170
278
  }
@@ -0,0 +1,316 @@
1
+ /**
2
+ * File-based OAuthClientProvider for MCP servers.
3
+ *
4
+ * Implements the full OAuth2 Authorization Code flow with PKCE and
5
+ * Dynamic Client Registration (RFC 7591) as required by the MCP spec.
6
+ *
7
+ * Token and client state are persisted per-server under ~/.pi/agent/mcp-auth/
8
+ * so they survive pi restarts without requiring re-authorization.
9
+ *
10
+ * Usage: config adds `auth: { ... }` to a server config. This module
11
+ * constructs an OAuthClientProvider and the transport factory wires it in.
12
+ */
13
+
14
+ import type { OAuthClientProvider, OAuthDiscoveryState } from "@modelcontextprotocol/sdk/client/auth.js";
15
+ import type { OAuthClientMetadata, OAuthClientInformationMixed, OAuthTokens } from "@modelcontextprotocol/sdk/shared/auth.js";
16
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
17
+ import { join } from "node:path";
18
+ import { homedir } from "node:os";
19
+ import { createHash } from "node:crypto";
20
+
21
+ // ─── Types ────────────────────────────────────────────────────────────────────
22
+
23
+ /** Auth config in mcp.json server entry. Matches the Zod AuthConfigSchema in config.ts. */
24
+ export interface AuthConfig {
25
+ /** Auth type. Currently only "oauth" is supported. */
26
+ type?: "oauth" | undefined;
27
+ /**
28
+ * Callback URL the OAuth server redirects to after authorization.
29
+ * Default: auto-detected local callback server.
30
+ */
31
+ redirectUrl?: string | undefined;
32
+ /**
33
+ * Optional scope to request during authorization.
34
+ */
35
+ scope?: string | undefined;
36
+ /**
37
+ * Pre-registered client_id (skip dynamic client registration).
38
+ */
39
+ clientId?: string | undefined;
40
+ /**
41
+ * Pre-registered client_secret.
42
+ */
43
+ clientSecret?: string | undefined;
44
+ }
45
+
46
+ // ─── Persistent State Types ───────────────────────────────────────────────────
47
+
48
+ interface StoredClientInfo {
49
+ client_id: string;
50
+ client_secret: string | undefined;
51
+ }
52
+
53
+ interface StoredTokens {
54
+ access_token: string;
55
+ token_type: string | undefined;
56
+ refresh_token: string | undefined;
57
+ expires_in: number | undefined;
58
+ scope: string | undefined;
59
+ /** ISO timestamp when tokens were saved (for expiry estimation). */
60
+ saved_at: string | undefined;
61
+ }
62
+
63
+ interface StoredState {
64
+ clientInfo: StoredClientInfo | undefined;
65
+ tokens: StoredTokens | undefined;
66
+ codeVerifier: string | undefined;
67
+ discoveryState: OAuthDiscoveryState | undefined;
68
+ }
69
+
70
+ // ─── File helpers ─────────────────────────────────────────────────────────────
71
+
72
+ const AUTH_DIR = join(homedir(), ".pi", "agent", "mcp-auth");
73
+
74
+ function statePath(serverName: string): string {
75
+ // Hash the server name to avoid filesystem issues with special chars
76
+ const hash = createHash("sha256").update(serverName).digest("hex").slice(0, 16);
77
+ return join(AUTH_DIR, `${hash}.json`);
78
+ }
79
+
80
+ async function loadState(serverName: string): Promise<StoredState> {
81
+ try {
82
+ const raw = await readFile(statePath(serverName), "utf8");
83
+ const parsed = JSON.parse(raw) as StoredState;
84
+ return {
85
+ clientInfo: parsed.clientInfo ?? undefined,
86
+ tokens: parsed.tokens ?? undefined,
87
+ codeVerifier: parsed.codeVerifier ?? undefined,
88
+ discoveryState: parsed.discoveryState ?? undefined,
89
+ };
90
+ } catch {
91
+ return {
92
+ clientInfo: undefined,
93
+ tokens: undefined,
94
+ codeVerifier: undefined,
95
+ discoveryState: undefined,
96
+ };
97
+ }
98
+ }
99
+
100
+ async function saveState(serverName: string, state: StoredState): Promise<void> {
101
+ await mkdir(AUTH_DIR, { recursive: true });
102
+ // Only write defined fields
103
+ const toWrite: Record<string, unknown> = {};
104
+ if (state.clientInfo !== undefined) toWrite.clientInfo = state.clientInfo;
105
+ if (state.tokens !== undefined) toWrite.tokens = state.tokens;
106
+ if (state.codeVerifier !== undefined) toWrite.codeVerifier = state.codeVerifier;
107
+ if (state.discoveryState !== undefined) toWrite.discoveryState = state.discoveryState;
108
+ await writeFile(statePath(serverName), JSON.stringify(toWrite, null, 2), "utf8");
109
+ }
110
+
111
+ // ─── OAuthClientProvider Implementation ────────────────────────────────────────
112
+
113
+ export class McpOAuthProvider implements OAuthClientProvider {
114
+ private serverName: string;
115
+ private authConfig: AuthConfig;
116
+ private _redirectUrl: string | undefined;
117
+ private _onAuthRequired: ((url: URL) => void | Promise<void>) | undefined;
118
+
119
+ constructor(
120
+ serverName: string,
121
+ authConfig: AuthConfig,
122
+ onAuthRequired?: (url: URL) => void | Promise<void>,
123
+ ) {
124
+ this.serverName = serverName;
125
+ this.authConfig = authConfig;
126
+ this._redirectUrl = authConfig.redirectUrl;
127
+ this._onAuthRequired = onAuthRequired;
128
+ }
129
+
130
+ // --- redirectUrl ---
131
+
132
+ get redirectUrl(): string | URL | undefined {
133
+ return this._redirectUrl;
134
+ }
135
+
136
+ // --- clientMetadata ---
137
+
138
+ get clientMetadata(): OAuthClientMetadata {
139
+ return {
140
+ client_name: `pi-mcp/${this.serverName}`,
141
+ redirect_uris: this._redirectUrl ? [this._redirectUrl] : [],
142
+ grant_types: ["authorization_code", "refresh_token"],
143
+ response_types: ["code"],
144
+ token_endpoint_auth_method: this.authConfig.clientSecret ? "client_secret_basic" : "none",
145
+ };
146
+ }
147
+
148
+ // --- clientInformation ---
149
+
150
+ async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
151
+ // If static credentials provided, use those
152
+ if (this.authConfig.clientId) {
153
+ return {
154
+ client_id: this.authConfig.clientId,
155
+ ...(this.authConfig.clientSecret && { client_secret: this.authConfig.clientSecret }),
156
+ };
157
+ }
158
+ // Otherwise load from persisted DCR state
159
+ const state = await loadState(this.serverName);
160
+ if (state.clientInfo) {
161
+ return {
162
+ client_id: state.clientInfo.client_id,
163
+ ...(state.clientInfo.client_secret && { client_secret: state.clientInfo.client_secret }),
164
+ };
165
+ }
166
+ return undefined;
167
+ }
168
+
169
+ // --- saveClientInformation (DCR) ---
170
+
171
+ async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise<void> {
172
+ const state = await loadState(this.serverName);
173
+ state.clientInfo = {
174
+ client_id: clientInformation.client_id,
175
+ client_secret: clientInformation.client_secret,
176
+ };
177
+ await saveState(this.serverName, state);
178
+ }
179
+
180
+ // --- tokens ---
181
+
182
+ async tokens(): Promise<OAuthTokens | undefined> {
183
+ const state = await loadState(this.serverName);
184
+ if (!state.tokens) return undefined;
185
+ // Build OAuthTokens — only include defined fields
186
+ const tokens: Record<string, string> = {
187
+ access_token: state.tokens.access_token,
188
+ };
189
+ if (state.tokens.token_type !== undefined) tokens.token_type = state.tokens.token_type;
190
+ if (state.tokens.refresh_token !== undefined) tokens.refresh_token = state.tokens.refresh_token;
191
+ return tokens as unknown as OAuthTokens;
192
+ }
193
+
194
+ // --- saveTokens ---
195
+
196
+ async saveTokens(tokens: OAuthTokens): Promise<void> {
197
+ const state = await loadState(this.serverName);
198
+ state.tokens = {
199
+ access_token: tokens.access_token,
200
+ token_type: tokens.token_type,
201
+ refresh_token: tokens.refresh_token,
202
+ expires_in: tokens.expires_in,
203
+ scope: tokens.scope,
204
+ saved_at: new Date().toISOString(),
205
+ };
206
+ await saveState(this.serverName, state);
207
+ }
208
+
209
+ // --- redirectToAuthorization ---
210
+
211
+ async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
212
+ if (this._onAuthRequired) {
213
+ await this._onAuthRequired(authorizationUrl);
214
+ } else {
215
+ // Fallback: just log it
216
+ console.error(
217
+ `[pi-mcp] OAuth authorization required for "${this.serverName}".`,
218
+ `\n Open this URL in your browser: ${authorizationUrl.toString()}`,
219
+ );
220
+ }
221
+ }
222
+
223
+ // --- PKCE code verifier ---
224
+
225
+ async saveCodeVerifier(codeVerifier: string): Promise<void> {
226
+ const state = await loadState(this.serverName);
227
+ state.codeVerifier = codeVerifier;
228
+ await saveState(this.serverName, state);
229
+ }
230
+
231
+ async codeVerifier(): Promise<string> {
232
+ const state = await loadState(this.serverName);
233
+ if (!state.codeVerifier) {
234
+ throw new Error(`[pi-mcp] No PKCE code verifier found for "${this.serverName}"`);
235
+ }
236
+ return state.codeVerifier;
237
+ }
238
+
239
+ // --- Discovery state caching ---
240
+
241
+ async saveDiscoveryState(discState: OAuthDiscoveryState): Promise<void> {
242
+ const state = await loadState(this.serverName);
243
+ state.discoveryState = discState;
244
+ await saveState(this.serverName, state);
245
+ }
246
+
247
+ async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
248
+ const state = await loadState(this.serverName);
249
+ return state.discoveryState;
250
+ }
251
+
252
+ // --- Credential invalidation ---
253
+
254
+ async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
255
+ const state = await loadState(this.serverName);
256
+ switch (scope) {
257
+ case "all":
258
+ state.clientInfo = undefined;
259
+ state.tokens = undefined;
260
+ state.codeVerifier = undefined;
261
+ state.discoveryState = undefined;
262
+ break;
263
+ case "client":
264
+ state.clientInfo = undefined;
265
+ break;
266
+ case "tokens":
267
+ state.tokens = undefined;
268
+ break;
269
+ case "verifier":
270
+ state.codeVerifier = undefined;
271
+ break;
272
+ case "discovery":
273
+ state.discoveryState = undefined;
274
+ break;
275
+ }
276
+ await saveState(this.serverName, state);
277
+ }
278
+ }
279
+
280
+ // ─── Public Helpers ────────────────────────────────────────────────────────────
281
+
282
+ /**
283
+ * Get auth status info for a server — whether tokens exist, when they were saved, etc.
284
+ * Returns null if no auth state file exists at all.
285
+ */
286
+ export async function getAuthStatus(serverName: string): Promise<{
287
+ hasTokens: boolean;
288
+ hasClientInfo: boolean;
289
+ savedAt: string | undefined;
290
+ scope: string | undefined;
291
+ } | null> {
292
+ const state = await loadState(serverName);
293
+ if (
294
+ state.clientInfo === undefined &&
295
+ state.tokens === undefined &&
296
+ state.codeVerifier === undefined &&
297
+ state.discoveryState === undefined
298
+ ) {
299
+ return null;
300
+ }
301
+ return {
302
+ hasTokens: state.tokens !== undefined,
303
+ hasClientInfo: state.clientInfo !== undefined,
304
+ savedAt: state.tokens?.saved_at,
305
+ scope: state.tokens?.scope,
306
+ };
307
+ }
308
+
309
+ /**
310
+ * Reset all OAuth state for a server (tokens, client info, PKCE verifier, discovery).
311
+ * Used to force re-authorization on next connection.
312
+ */
313
+ export async function resetAuth(serverName: string): Promise<void> {
314
+ const provider = new McpOAuthProvider(serverName, {});
315
+ await provider.invalidateCredentials("all");
316
+ }
@@ -23,7 +23,9 @@ import {
23
23
  LoggingMessageNotificationSchema,
24
24
  } from "@modelcontextprotocol/sdk/types.js";
25
25
  import { McpError } from "./errors.js";
26
- import type { McpConfig, ServerConfig, Settings } from "./config.js";
26
+ import type { McpConfig, ServerConfig, Settings, AuthConfig } from "./config.js";
27
+ import { McpOAuthProvider, getAuthStatus, resetAuth } from "./oauth-provider.js";
28
+ import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js";
27
29
 
28
30
  // ─── Types ────────────────────────────────────────────────────────────────────
29
31
 
@@ -54,12 +56,36 @@ export interface ManagedServer {
54
56
  /** Called after tool list is refreshed for a server (e.g. on list_changed notification). */
55
57
  export type ToolRefreshCallback = (serverName: string, client: Client) => Promise<void>;
56
58
 
59
+ export interface TransportAuthCallbacks {
60
+ /** Called when OAuth authorization is required (browser redirect needed). */
61
+ onAuthRequired: (serverName: string, authorizationUrl: URL) => void | Promise<void>;
62
+ }
63
+
57
64
  // ─── Transport Factory ────────────────────────────────────────────────────────
58
65
 
59
66
  function createTransport(
67
+ serverName: string,
60
68
  config: ServerConfig,
61
69
  onStderr: (line: string) => void,
70
+ authCallbacks?: TransportAuthCallbacks,
62
71
  ): Transport {
72
+ // Build requestInit for static headers (API keys, etc.)
73
+ const requestInit: RequestInit | undefined = config.headers
74
+ ? { headers: config.headers }
75
+ : undefined;
76
+
77
+ // Build OAuth authProvider if auth config is present
78
+ let authProvider: OAuthClientProvider | undefined;
79
+ if (config.auth && config.transport !== "stdio") {
80
+ authProvider = new McpOAuthProvider(
81
+ serverName,
82
+ config.auth,
83
+ authCallbacks
84
+ ? (url: URL) => authCallbacks.onAuthRequired(serverName, url)
85
+ : undefined,
86
+ );
87
+ }
88
+
63
89
  switch (config.transport) {
64
90
  case "stdio": {
65
91
  // Build clean env: process.env may contain undefined values,
@@ -83,11 +109,21 @@ function createTransport(
83
109
  return transport;
84
110
  }
85
111
  case "streamable-http":
86
- // Cast needed: exactOptionalPropertyTypes conflicts with sessionId?: string vs string
87
- return new StreamableHTTPClientTransport(new URL(config.url!)) as unknown as Transport;
112
+ return new StreamableHTTPClientTransport(
113
+ new URL(config.url!),
114
+ {
115
+ ...(requestInit && { requestInit }),
116
+ ...(authProvider && { authProvider }),
117
+ },
118
+ ) as unknown as Transport;
88
119
  case "sse":
89
- // Legacy HTTP+SSE transport (2024-11-05 spec) — backwards compatibility
90
- return new SSEClientTransport(new URL(config.url!));
120
+ return new SSEClientTransport(
121
+ new URL(config.url!),
122
+ {
123
+ ...(requestInit && { requestInit }),
124
+ ...(authProvider && { authProvider }),
125
+ },
126
+ );
91
127
  }
92
128
  }
93
129
 
@@ -98,8 +134,11 @@ export class ServerManager {
98
134
  private settings: Settings;
99
135
  private onToolRefresh: ToolRefreshCallback | null = null;
100
136
 
101
- constructor(config: McpConfig) {
137
+ private authCallbacks: TransportAuthCallbacks | undefined;
138
+
139
+ constructor(config: McpConfig, authCallbacks?: TransportAuthCallbacks) {
102
140
  this.settings = config.settings;
141
+ this.authCallbacks = authCallbacks;
103
142
  for (const [name, serverConfig] of Object.entries(config.mcpServers)) {
104
143
  this.servers.set(name, {
105
144
  name,
@@ -146,6 +185,21 @@ export class ServerManager {
146
185
  return [`MCP: ${ready}/${all.length} servers ready`, ...lines].join("\n");
147
186
  }
148
187
 
188
+ /** Reset OAuth credentials for a server, forcing re-authorization on next connect. */
189
+ async resetServerAuth(name: string): Promise<void> {
190
+ await resetAuth(name);
191
+ }
192
+
193
+ /** Get auth status for a server. */
194
+ async getServerAuthStatus(name: string): Promise<{
195
+ hasTokens: boolean;
196
+ hasClientInfo: boolean;
197
+ savedAt: string | undefined;
198
+ scope: string | undefined;
199
+ } | null> {
200
+ return getAuthStatus(name);
201
+ }
202
+
149
203
  /** Get recent stderr output for a server. */
150
204
  getServerLogs(name: string): string {
151
205
  const server = this.servers.get(name);
@@ -227,7 +281,7 @@ export class ServerManager {
227
281
 
228
282
  let transport: Transport;
229
283
  try {
230
- transport = createTransport(server.config, appendStderr);
284
+ transport = createTransport(server.name, server.config, appendStderr, this.authCallbacks);
231
285
  } catch (err) {
232
286
  server.state = "stopped";
233
287
  server.lastError = err instanceof Error ? err : new Error(String(err));
@@ -30,13 +30,20 @@ export type PiExtensionAPI = Pick<ExtensionAPI, "registerTool" | "getActiveTools
30
30
 
31
31
  /**
32
32
  * Convert a JSON Schema object to a TypeBox schema.
33
- * Handles the common subset used by real-world MCP servers.
34
- * Falls back to Type.Any() for unsupported constructs ($ref, oneOf, etc.)
35
- * and logs a warning so users can report gaps.
33
+ * Handles the common subset used by real-world MCP servers:
34
+ * - Primitives (string, number, integer, boolean, null)
35
+ * - Arrays, objects (with required/optional/additionalProperties)
36
+ * - Enums (string enums → Union of Literals)
37
+ * - Nullable types ("type": ["string", "null"])
38
+ * - $ref (local #/$defs/ and #/definitions/ references)
39
+ * - oneOf / anyOf → TypeBox Union
40
+ * - allOf → TypeBox Intersect
41
+ * Falls back to Type.Any() for unresolvable $ref or missing type.
36
42
  */
37
43
  export function convertJsonSchemaToTypebox(
38
44
  schema: unknown,
39
45
  depth = 0,
46
+ defs?: Record<string, unknown>,
40
47
  ): TSchema {
41
48
  // Guard against infinite recursion and malformed schemas
42
49
  if (!schema || typeof schema !== "object" || Array.isArray(schema) || depth > 10) {
@@ -47,6 +54,71 @@ export function convertJsonSchemaToTypebox(
47
54
  const description = typeof s["description"] === "string" ? s["description"] : undefined;
48
55
  const opts = description ? { description } : {};
49
56
 
57
+ // Extract $defs / definitions for $ref resolution (carried through recursive calls)
58
+ const resolvedDefs: Record<string, unknown> = {
59
+ ...((s["$defs"] ?? s["definitions"]) as Record<string, unknown> | undefined),
60
+ ...defs,
61
+ };
62
+
63
+ // ── Handle $ref ──────────────────────────────────────────────────────────
64
+ if (typeof s["$ref"] === "string") {
65
+ const ref = s["$ref"] as string;
66
+ let resolved: unknown;
67
+
68
+ // Local references: #/$defs/Foo, #/definitions/Foo
69
+ if (ref.startsWith("#/")) {
70
+ const parts = ref.slice(2).split("/");
71
+ if (parts[0] === "$defs" || parts[0] === "definitions") {
72
+ const key = parts.slice(1).join("/");
73
+ resolved = resolvedDefs[key];
74
+ } else {
75
+ // Fallback: try walking the defs map by the last part
76
+ const key = parts[parts.length - 1]!;
77
+ resolved = resolvedDefs[key];
78
+ }
79
+ } else {
80
+ // External $ref — cannot resolve, fall back
81
+ console.warn(
82
+ `[pi-mcp] Cannot resolve external $ref "${ref}", using Type.Any()`,
83
+ );
84
+ return Type.Any(opts);
85
+ }
86
+
87
+ if (!resolved) {
88
+ console.warn(
89
+ `[pi-mcp] Could not resolve $ref "${ref}", using Type.Any()`,
90
+ );
91
+ return Type.Any(opts);
92
+ }
93
+
94
+ // Merge description from referencing schema into resolved schema
95
+ const merged = { ...(resolved as Record<string, unknown>) };
96
+ if (description && !merged["description"]) {
97
+ merged["description"] = description;
98
+ }
99
+ return convertJsonSchemaToTypebox(merged, depth + 1, resolvedDefs);
100
+ }
101
+
102
+ // ── Handle oneOf / anyOf → TypeBox Union ─────────────────────────────────
103
+ if (Array.isArray(s["oneOf"])) {
104
+ const members = (s["oneOf"] as unknown[])
105
+ .map((sub) => convertJsonSchemaToTypebox(sub, depth + 1, resolvedDefs));
106
+ return members.length === 1 ? members[0]! : Type.Union(members, opts);
107
+ }
108
+
109
+ if (Array.isArray(s["anyOf"])) {
110
+ const members = (s["anyOf"] as unknown[])
111
+ .map((sub) => convertJsonSchemaToTypebox(sub, depth + 1, resolvedDefs));
112
+ return members.length === 1 ? members[0]! : Type.Union(members, opts);
113
+ }
114
+
115
+ // ── Handle allOf → TypeBox Intersect ─────────────────────────────────────
116
+ if (Array.isArray(s["allOf"])) {
117
+ const members = (s["allOf"] as unknown[])
118
+ .map((sub) => convertJsonSchemaToTypebox(sub, depth + 1, resolvedDefs));
119
+ return members.length === 1 ? members[0]! : Type.Intersect(members, opts);
120
+ }
121
+
50
122
  // Handle nullable types: { "type": ["string", "null"] }
51
123
  const rawType = s["type"];
52
124
  const type = Array.isArray(rawType)
@@ -84,7 +156,7 @@ export function convertJsonSchemaToTypebox(
84
156
  case "array": {
85
157
  const items = s["items"];
86
158
  base = Type.Array(
87
- items ? convertJsonSchemaToTypebox(items, depth + 1) : Type.Unknown(),
159
+ items ? convertJsonSchemaToTypebox(items, depth + 1, resolvedDefs) : Type.Unknown(),
88
160
  opts,
89
161
  );
90
162
  break;
@@ -104,7 +176,7 @@ export function convertJsonSchemaToTypebox(
104
176
 
105
177
  const props: Record<string, TSchema> = {};
106
178
  for (const [key, value] of Object.entries(properties)) {
107
- const converted = convertJsonSchemaToTypebox(value, depth + 1);
179
+ const converted = convertJsonSchemaToTypebox(value, depth + 1, resolvedDefs);
108
180
  props[key] = required.has(key) ? converted : Type.Optional(converted);
109
181
  }
110
182
 
@@ -117,12 +189,7 @@ export function convertJsonSchemaToTypebox(
117
189
  break;
118
190
  }
119
191
  default: {
120
- // Unsupported: $ref, oneOf, anyOf, allOf, or missing type field
121
- if (s["oneOf"] || s["anyOf"] || s["allOf"] || s["$ref"]) {
122
- console.error(
123
- `[pi-mcp] Unsupported JSON Schema construct (oneOf/anyOf/allOf/$ref), using Type.Any(). Schema: ${JSON.stringify(s).slice(0, 200)}`,
124
- );
125
- }
192
+ // Truly unsupported or missing type field
126
193
  base = Type.Any(opts);
127
194
  break;
128
195
  }