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 +7 -7
- package/package.json +4 -4
- package/src/config.ts +36 -0
- package/src/index.ts +109 -1
- package/src/oauth-provider.ts +316 -0
- package/src/server-manager.ts +61 -7
- package/src/tool-bridge.ts +78 -11
package/README.md
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
# pi-mcp
|
|
1
|
+
# pi-mcp-extension
|
|
2
2
|
|
|
3
|
-
[](https://www.npmjs.com/package/pi-mcp)
|
|
4
|
-
[](https://github.com/irahardianto/pi-mcp/blob/main/LICENSE)
|
|
3
|
+
[](https://www.npmjs.com/package/pi-mcp-extension)
|
|
4
|
+
[](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
|
+
"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
|
-
|
|
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
|
+
}
|
package/src/server-manager.ts
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
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
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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));
|
package/src/tool-bridge.ts
CHANGED
|
@@ -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
|
-
*
|
|
35
|
-
*
|
|
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
|
-
//
|
|
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
|
}
|