pi-mcp-extension 1.3.1 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/callback-server.ts +263 -0
- package/src/config.ts +36 -0
- package/src/index.ts +202 -1
- package/src/oauth-provider.ts +367 -0
- package/src/server-manager.ts +61 -7
package/package.json
CHANGED
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP OAuth Callback Server
|
|
3
|
+
*
|
|
4
|
+
* Simple HTTP server that handles OAuth callbacks from the authorization server.
|
|
5
|
+
* Uses Node.js http module for compatibility with no external dependencies.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createServer, type Server, type IncomingMessage, type ServerResponse } from "http";
|
|
9
|
+
import { URL } from "url";
|
|
10
|
+
|
|
11
|
+
interface PendingAuth {
|
|
12
|
+
resolve: (code: string) => void;
|
|
13
|
+
reject: (error: Error) => void;
|
|
14
|
+
timeout: ReturnType<typeof setTimeout>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
let server: Server | null = null;
|
|
18
|
+
let actualServerPort: number | null = null;
|
|
19
|
+
const pendingAuths = new Map<string, PendingAuth>();
|
|
20
|
+
|
|
21
|
+
const DEFAULT_PORT = 19876;
|
|
22
|
+
const CALLBACK_PATH = "/callback";
|
|
23
|
+
const TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
|
24
|
+
|
|
25
|
+
const HTML_SUCCESS = `<!DOCTYPE html>
|
|
26
|
+
<html><head><title>Pi - Authorization Successful</title>
|
|
27
|
+
<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
|
|
28
|
+
.container{text-align:center;padding:2rem}h1{color:#4ade80;margin-bottom:1rem}p{color:#aaa}</style>
|
|
29
|
+
</head><body>
|
|
30
|
+
<div class="container"><h1>✓ Authorization Successful</h1><p>You can close this window and return to Pi.</p></div>
|
|
31
|
+
<script>setTimeout(()=>window.close(),2000)</script>
|
|
32
|
+
</body></html>`;
|
|
33
|
+
|
|
34
|
+
const HTML_ERROR = (error: string) => `<!DOCTYPE html>
|
|
35
|
+
<html><head><title>Pi - Authorization Failed</title>
|
|
36
|
+
<style>body{font-family:system-ui,-apple-system,sans-serif;display:flex;justify-content:center;align-items:center;height:100vh;margin:0;background:#1a1a2e;color:#eee}
|
|
37
|
+
.container{text-align:center;padding:2rem}h1{color:#f87171;margin-bottom:1rem}p{color:#aaa}
|
|
38
|
+
.error{color:#fca5a5;font-family:monospace;margin-top:1rem;padding:1rem;background:rgba(248,113,113,0.1);border-radius:.5rem;white-space:pre-wrap;word-break:break-word}</style>
|
|
39
|
+
</head><body>
|
|
40
|
+
<div class="container"><h1>✗ Authorization Failed</h1><p>An error occurred during authorization.</p><div class="error">${escapeHtml(error)}</div></div>
|
|
41
|
+
</body></html>`;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Escape HTML entities to prevent XSS attacks.
|
|
45
|
+
*/
|
|
46
|
+
function escapeHtml(text: string): string {
|
|
47
|
+
return text
|
|
48
|
+
.replace(/&/g, "&")
|
|
49
|
+
.replace(/</g, "<")
|
|
50
|
+
.replace(/>/g, ">")
|
|
51
|
+
.replace(/"/g, """)
|
|
52
|
+
.replace(/'/g, "'");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Handle incoming HTTP requests to the callback server.
|
|
57
|
+
*/
|
|
58
|
+
function handleRequest(req: IncomingMessage, res: ServerResponse): void {
|
|
59
|
+
const url = new URL(req.url || "/", `http://${req.headers.host}`);
|
|
60
|
+
|
|
61
|
+
// Only handle the callback path
|
|
62
|
+
if (url.pathname !== CALLBACK_PATH) {
|
|
63
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
64
|
+
res.end("Not found");
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const code = url.searchParams.get("code");
|
|
69
|
+
const state = url.searchParams.get("state");
|
|
70
|
+
const error = url.searchParams.get("error");
|
|
71
|
+
const errorDescription = url.searchParams.get("error_description");
|
|
72
|
+
|
|
73
|
+
// Enforce state parameter presence for CSRF protection
|
|
74
|
+
if (!state) {
|
|
75
|
+
const errorMsg = "Missing required state parameter - potential CSRF attack";
|
|
76
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
77
|
+
res.end(HTML_ERROR(errorMsg));
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Handle OAuth errors
|
|
82
|
+
if (error) {
|
|
83
|
+
const errorMsg = errorDescription || error;
|
|
84
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
85
|
+
res.end(HTML_ERROR(errorMsg));
|
|
86
|
+
if (pendingAuths.has(state)) {
|
|
87
|
+
const pending = pendingAuths.get(state)!;
|
|
88
|
+
clearTimeout(pending.timeout);
|
|
89
|
+
pendingAuths.delete(state);
|
|
90
|
+
setTimeout(() => pending.reject(new Error(errorMsg)), 0);
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Require authorization code
|
|
96
|
+
if (!code) {
|
|
97
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
98
|
+
res.end(HTML_ERROR("No authorization code provided"));
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Validate state parameter
|
|
103
|
+
if (!pendingAuths.has(state)) {
|
|
104
|
+
const errorMsg = "Invalid or expired state parameter - potential CSRF attack";
|
|
105
|
+
res.writeHead(400, { "Content-Type": "text/html" });
|
|
106
|
+
res.end(HTML_ERROR(errorMsg));
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const pending = pendingAuths.get(state)!;
|
|
111
|
+
|
|
112
|
+
// Clear timeout and resolve the pending promise
|
|
113
|
+
clearTimeout(pending.timeout);
|
|
114
|
+
pendingAuths.delete(state);
|
|
115
|
+
pending.resolve(code);
|
|
116
|
+
|
|
117
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
118
|
+
res.end(HTML_SUCCESS);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Ensure the callback server is running.
|
|
123
|
+
* Scans forward for an available local port if the preferred port is busy.
|
|
124
|
+
*
|
|
125
|
+
* @param preferredPort - The preferred port to use (default: 19876)
|
|
126
|
+
* @returns The actual port the server is listening on
|
|
127
|
+
*/
|
|
128
|
+
export async function ensureCallbackServer(preferredPort: number = DEFAULT_PORT): Promise<number> {
|
|
129
|
+
if (server) {
|
|
130
|
+
// Already running, return the tracked actual port
|
|
131
|
+
if (actualServerPort !== null) {
|
|
132
|
+
return actualServerPort;
|
|
133
|
+
}
|
|
134
|
+
// Fallback: try to get the port from the server
|
|
135
|
+
const address = server.address();
|
|
136
|
+
if (address && typeof address === "object" && "port" in address) {
|
|
137
|
+
actualServerPort = address.port;
|
|
138
|
+
return address.port;
|
|
139
|
+
}
|
|
140
|
+
// If we still can't determine the port, return the preferred port
|
|
141
|
+
// (this shouldn't happen in practice)
|
|
142
|
+
return preferredPort;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const maxAttempts = 25; // Try up to 25 ports
|
|
146
|
+
|
|
147
|
+
for (let offset = 0; offset < maxAttempts; offset++) {
|
|
148
|
+
const candidatePort = preferredPort + offset;
|
|
149
|
+
const candidateServer = createServer(handleRequest);
|
|
150
|
+
|
|
151
|
+
try {
|
|
152
|
+
await new Promise<void>((resolve, reject) => {
|
|
153
|
+
candidateServer.once("error", (err: any) => {
|
|
154
|
+
reject(err);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Bind to 127.0.0.1 explicitly (IPv4) to avoid issues with IPv6
|
|
158
|
+
candidateServer.listen(candidatePort, "127.0.0.1", () => {
|
|
159
|
+
resolve();
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
server = candidateServer;
|
|
164
|
+
actualServerPort = candidatePort;
|
|
165
|
+
server.unref(); // Don't block process exit
|
|
166
|
+
return candidatePort;
|
|
167
|
+
} catch (error) {
|
|
168
|
+
const nodeError = error as NodeJS.ErrnoException;
|
|
169
|
+
await new Promise<void>((resolve) => {
|
|
170
|
+
candidateServer.close(() => resolve());
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// If not EADDRINUSE, rethrow
|
|
174
|
+
if (nodeError.code !== "EADDRINUSE") {
|
|
175
|
+
throw error;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
throw new Error(
|
|
181
|
+
`OAuth callback port ${preferredPort} is already in use and no free port was found in range ${preferredPort}-${preferredPort + maxAttempts - 1}`
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Wait for a callback with the given OAuth state.
|
|
187
|
+
* Returns a promise that resolves with the authorization code.
|
|
188
|
+
*
|
|
189
|
+
* @param oauthState - The OAuth state parameter to wait for
|
|
190
|
+
* @param timeoutMs - Timeout in milliseconds (default: 5 minutes)
|
|
191
|
+
* @returns Promise that resolves with the authorization code
|
|
192
|
+
*/
|
|
193
|
+
export function waitForCallback(
|
|
194
|
+
oauthState: string,
|
|
195
|
+
timeoutMs: number = TIMEOUT_MS
|
|
196
|
+
): Promise<string> {
|
|
197
|
+
return new Promise((resolve, reject) => {
|
|
198
|
+
const timeout = setTimeout(() => {
|
|
199
|
+
if (pendingAuths.has(oauthState)) {
|
|
200
|
+
pendingAuths.delete(oauthState);
|
|
201
|
+
reject(new Error("OAuth callback timeout - authorization took too long"));
|
|
202
|
+
}
|
|
203
|
+
}, timeoutMs);
|
|
204
|
+
|
|
205
|
+
pendingAuths.set(oauthState, { resolve, reject, timeout });
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Cancel a pending authorization by state.
|
|
211
|
+
*
|
|
212
|
+
* @param oauthState - The OAuth state to cancel
|
|
213
|
+
*/
|
|
214
|
+
export function cancelCallback(oauthState: string): void {
|
|
215
|
+
const pending = pendingAuths.get(oauthState);
|
|
216
|
+
if (pending) {
|
|
217
|
+
clearTimeout(pending.timeout);
|
|
218
|
+
pendingAuths.delete(oauthState);
|
|
219
|
+
pending.reject(new Error("Authorization cancelled"));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Stop the callback server and reject all pending authorizations.
|
|
225
|
+
*/
|
|
226
|
+
export async function stopCallbackServer(): Promise<void> {
|
|
227
|
+
if (server) {
|
|
228
|
+
await new Promise<void>((resolve) => {
|
|
229
|
+
server!.close(() => {
|
|
230
|
+
resolve();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
server = null;
|
|
234
|
+
actualServerPort = null;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Reject all pending auths (defer to allow any pending operations to complete)
|
|
238
|
+
const pendingList = Array.from(pendingAuths.entries());
|
|
239
|
+
pendingAuths.clear();
|
|
240
|
+
setTimeout(() => {
|
|
241
|
+
for (const [, pending] of pendingList) {
|
|
242
|
+
clearTimeout(pending.timeout);
|
|
243
|
+
pending.reject(new Error("OAuth callback server stopped"));
|
|
244
|
+
}
|
|
245
|
+
}, 0);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Check if the callback server is running.
|
|
250
|
+
*/
|
|
251
|
+
export function isCallbackServerRunning(): boolean {
|
|
252
|
+
return server !== null;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Get the number of pending authorizations.
|
|
257
|
+
*/
|
|
258
|
+
export function getPendingAuthCount(): number {
|
|
259
|
+
return pendingAuths.size;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Export constants for testing/config
|
|
263
|
+
export { DEFAULT_PORT, CALLBACK_PATH, TIMEOUT_MS };
|
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,43 @@
|
|
|
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
|
+
|
|
18
|
+
// OAuth imports
|
|
19
|
+
import {
|
|
20
|
+
ensureCallbackServer,
|
|
21
|
+
waitForCallback,
|
|
22
|
+
cancelCallback,
|
|
23
|
+
stopCallbackServer,
|
|
24
|
+
} from "./callback-server.js";
|
|
25
|
+
import { setCallbackPort, McpOAuthProvider } from "./oauth-provider.js";
|
|
26
|
+
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
|
|
27
|
+
import { auth } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Open a URL in the user's default browser.
|
|
31
|
+
* Works on macOS, Linux, and Windows.
|
|
32
|
+
*/
|
|
33
|
+
function openBrowser(url: string): void {
|
|
34
|
+
const cmd = process.platform === "darwin"
|
|
35
|
+
? `open "${url}"`
|
|
36
|
+
: process.platform === "win32"
|
|
37
|
+
? `start "" "${url}"`
|
|
38
|
+
: `xdg-open "${url}"`;
|
|
39
|
+
|
|
40
|
+
exec(cmd, (err, stdout, stderr) => {
|
|
41
|
+
if (err) {
|
|
42
|
+
const errorMsg = `[pi-mcp] Failed to open browser: ${err.message}`;
|
|
43
|
+
console.error(errorMsg);
|
|
44
|
+
if (stderr) {
|
|
45
|
+
console.error(`[pi-mcp] Browser error output: ${stderr}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
}
|
|
15
50
|
|
|
16
51
|
export default async function (pi: ExtensionAPI): Promise<void> {
|
|
17
52
|
// ── 1. Load and validate config ──────────────────────────────────────────
|
|
@@ -35,7 +70,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
35
70
|
}
|
|
36
71
|
|
|
37
72
|
// ── 2. Initialize bridge components ──────────────────────────────────────
|
|
38
|
-
|
|
73
|
+
// Auth callbacks — opens browser and notifies user when OAuth is needed
|
|
74
|
+
const authCallbacks: TransportAuthCallbacks = {
|
|
75
|
+
onAuthRequired: (serverName: string, authorizationUrl: URL): void => {
|
|
76
|
+
console.error(
|
|
77
|
+
`[pi-mcp] OAuth required for "${serverName}". Opening browser for authorization...`,
|
|
78
|
+
);
|
|
79
|
+
openBrowser(authorizationUrl.toString());
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const manager = new ServerManager(config, authCallbacks);
|
|
39
84
|
const bridge = new ToolBridge(config.settings, pi);
|
|
40
85
|
|
|
41
86
|
// Connect tool refresh callback: called on connect and on list_changed
|
|
@@ -86,6 +131,9 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
86
131
|
});
|
|
87
132
|
|
|
88
133
|
pi.on("session_shutdown", async (_event, _ctx: ExtensionContext) => {
|
|
134
|
+
// Stop the callback server
|
|
135
|
+
await stopCallbackServer().catch(() => {});
|
|
136
|
+
|
|
89
137
|
// Deactivate all tools before shutting down servers
|
|
90
138
|
for (const server of manager.getAllServers()) {
|
|
91
139
|
bridge.deactivateServer(server.name);
|
|
@@ -167,4 +215,157 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
167
215
|
}
|
|
168
216
|
},
|
|
169
217
|
});
|
|
218
|
+
|
|
219
|
+
// ── 7. /mcp:auth — trigger OAuth authentication for a server ────────────────
|
|
220
|
+
pi.registerCommand("mcp:auth", {
|
|
221
|
+
description:
|
|
222
|
+
"Trigger OAuth authentication for a server. Resets credentials and opens browser for re-authorization. Usage: /mcp:auth <server-name>",
|
|
223
|
+
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
224
|
+
const serverName = args.trim();
|
|
225
|
+
if (!serverName) {
|
|
226
|
+
// List servers with auth config
|
|
227
|
+
const authServers = manager.getAllServers().filter((s) => s.config.auth);
|
|
228
|
+
if (authServers.length === 0) {
|
|
229
|
+
ctx.ui.notify(
|
|
230
|
+
"pi-mcp: No servers with OAuth configured. Add `auth: { type: \"oauth\" }` to a server in mcp.json.",
|
|
231
|
+
"error",
|
|
232
|
+
);
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
const lines = authServers.map(async (s) => {
|
|
236
|
+
const status = await manager.getServerAuthStatus(s.name);
|
|
237
|
+
const authIcon = status?.hasTokens ? "\u2705 authenticated" : "\u274C not authenticated";
|
|
238
|
+
const savedInfo = status?.savedAt ? ` (since ${status.savedAt})` : "";
|
|
239
|
+
return ` ${s.name}: ${authIcon}${savedInfo}`;
|
|
240
|
+
});
|
|
241
|
+
const statusLines = await Promise.all(lines);
|
|
242
|
+
ctx.ui.notify(
|
|
243
|
+
[
|
|
244
|
+
"Usage: /mcp:auth <server-name>",
|
|
245
|
+
"",
|
|
246
|
+
"OAuth-enabled servers:",
|
|
247
|
+
...statusLines,
|
|
248
|
+
].join("\n"),
|
|
249
|
+
"info",
|
|
250
|
+
);
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
const server = manager.getServer(serverName);
|
|
254
|
+
if (!server) {
|
|
255
|
+
ctx.ui.notify(`pi-mcp: No server named "${serverName}"`, "error");
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (!server.config.auth) {
|
|
259
|
+
ctx.ui.notify(
|
|
260
|
+
`pi-mcp: Server "${serverName}" does not have OAuth configured. Add \`auth: { type: "oauth" }\` to its config in mcp.json.`,
|
|
261
|
+
"error",
|
|
262
|
+
);
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const config = server.config;
|
|
267
|
+
let oauthState: string | undefined;
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
// Stop the server if running
|
|
271
|
+
if (server.state !== "stopped") {
|
|
272
|
+
bridge.deactivateServer(serverName);
|
|
273
|
+
await manager.stopServer(serverName);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Validate that we have a server URL (required for OAuth)
|
|
277
|
+
if (!config.url) {
|
|
278
|
+
throw new McpError(
|
|
279
|
+
`Server "${serverName}" has OAuth configured but no URL. OAuth requires a URL-based server transport.`,
|
|
280
|
+
serverName,
|
|
281
|
+
"config",
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Reset all OAuth credentials (tokens, client info, PKCE, discovery)
|
|
286
|
+
await manager.resetServerAuth(serverName);
|
|
287
|
+
|
|
288
|
+
ctx.ui.notify(
|
|
289
|
+
`pi-mcp: Starting OAuth flow for ${serverName}...`,
|
|
290
|
+
"info",
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
// 1. Start the callback server
|
|
294
|
+
const port = await ensureCallbackServer();
|
|
295
|
+
setCallbackPort(port);
|
|
296
|
+
|
|
297
|
+
// 2. Generate a cryptographically secure state parameter for CSRF protection
|
|
298
|
+
oauthState = Array.from(crypto.getRandomValues(new Uint8Array(32)))
|
|
299
|
+
.map((b: number) => b.toString(16).padStart(2, "0"))
|
|
300
|
+
.join("");
|
|
301
|
+
|
|
302
|
+
// 3. Register the callback promise BEFORE opening the browser
|
|
303
|
+
const callbackPromise = waitForCallback(oauthState);
|
|
304
|
+
|
|
305
|
+
// 4. Create auth provider and transport
|
|
306
|
+
const authProvider = new McpOAuthProvider(
|
|
307
|
+
serverName,
|
|
308
|
+
config.auth || { type: "oauth" },
|
|
309
|
+
(url: URL) => {
|
|
310
|
+
console.error(`[pi-mcp] Opening browser for ${serverName}...`);
|
|
311
|
+
openBrowser(url.toString());
|
|
312
|
+
},
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
// CRITICAL FIX #1: Set the OAuth state on the provider before calling auth()
|
|
316
|
+
// This ensures the state parameter is included in the authorization URL
|
|
317
|
+
authProvider.setState(oauthState);
|
|
318
|
+
|
|
319
|
+
const transport = new StreamableHTTPClientTransport(
|
|
320
|
+
new URL(config.url),
|
|
321
|
+
{ authProvider },
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
// 5. Start the auth flow - this will trigger redirectToAuthorization which opens the browser
|
|
325
|
+
// CRITICAL FIX #2: Check the return value of auth() instead of catching UnauthorizedError
|
|
326
|
+
// The SDK returns 'REDIRECT' when it needs browser interaction, not an error
|
|
327
|
+
const authResult = await auth(authProvider, { serverUrl: config.url });
|
|
328
|
+
|
|
329
|
+
if (authResult === "AUTHORIZED") {
|
|
330
|
+
// Auth succeeded without needing browser interaction (e.g., had valid tokens)
|
|
331
|
+
ctx.ui.notify(`pi-mcp: ${serverName} authenticated successfully!`, "info");
|
|
332
|
+
} else if (authResult === "REDIRECT") {
|
|
333
|
+
// Browser was opened, wait for the callback from the user
|
|
334
|
+
ctx.ui.notify(
|
|
335
|
+
`pi-mcp: Browser opened for ${serverName}. Complete authorization to continue...`,
|
|
336
|
+
"info",
|
|
337
|
+
);
|
|
338
|
+
|
|
339
|
+
// 6. Wait for the callback (this blocks until the user authorizes)
|
|
340
|
+
const code = await callbackPromise;
|
|
341
|
+
|
|
342
|
+
// 7. Complete the OAuth flow with the authorization code
|
|
343
|
+
await transport.finishAuth(code);
|
|
344
|
+
|
|
345
|
+
ctx.ui.notify(`pi-mcp: ${serverName} authenticated successfully!`, "info");
|
|
346
|
+
} else {
|
|
347
|
+
throw new McpError(
|
|
348
|
+
`Unexpected auth result: ${authResult}`,
|
|
349
|
+
serverName,
|
|
350
|
+
"protocol",
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// 8. Close the transport (we'll create a new one when starting the server)
|
|
355
|
+
await transport.close().catch(() => {});
|
|
356
|
+
|
|
357
|
+
// 9. Start the server with fresh auth credentials
|
|
358
|
+
await manager.startServer(serverName, ctx.cwd);
|
|
359
|
+
|
|
360
|
+
} catch (err) {
|
|
361
|
+
const msg = err instanceof McpError ? err.userMessage : String(err);
|
|
362
|
+
ctx.ui.notify(`pi-mcp: Authentication failed for ${serverName} — ${msg}`, "error");
|
|
363
|
+
|
|
364
|
+
// Clean up on error
|
|
365
|
+
if (oauthState) {
|
|
366
|
+
cancelCallback(oauthState);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
},
|
|
370
|
+
});
|
|
170
371
|
}
|
|
@@ -0,0 +1,367 @@
|
|
|
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, unlink } 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
|
+
// ─── Callback Server Port ───────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
let callbackPort = 19876;
|
|
49
|
+
|
|
50
|
+
/** Set the callback server port. Called by the auth flow when the server starts. */
|
|
51
|
+
export function setCallbackPort(port: number): void {
|
|
52
|
+
callbackPort = port;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Get the current callback server port. */
|
|
56
|
+
export function getCallbackPort(): number {
|
|
57
|
+
return callbackPort;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// ─── Persistent State Types ───────────────────────────────────────────────────
|
|
61
|
+
|
|
62
|
+
interface StoredClientInfo {
|
|
63
|
+
client_id: string;
|
|
64
|
+
client_secret: string | undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface StoredTokens {
|
|
68
|
+
access_token: string;
|
|
69
|
+
token_type: string | undefined;
|
|
70
|
+
refresh_token: string | undefined;
|
|
71
|
+
expires_in: number | undefined;
|
|
72
|
+
scope: string | undefined;
|
|
73
|
+
/** ISO timestamp when tokens were saved (for expiry estimation). */
|
|
74
|
+
saved_at: string | undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface StoredState {
|
|
78
|
+
clientInfo: StoredClientInfo | undefined;
|
|
79
|
+
tokens: StoredTokens | undefined;
|
|
80
|
+
codeVerifier: string | undefined;
|
|
81
|
+
discoveryState: OAuthDiscoveryState | undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── File helpers ─────────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
const AUTH_DIR = join(homedir(), ".pi", "agent", "mcp-auth");
|
|
87
|
+
|
|
88
|
+
function statePath(serverName: string): string {
|
|
89
|
+
// Hash the server name to avoid filesystem issues with special chars
|
|
90
|
+
const hash = createHash("sha256").update(serverName).digest("hex").slice(0, 16);
|
|
91
|
+
return join(AUTH_DIR, `${hash}.json`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function loadState(serverName: string): Promise<StoredState> {
|
|
95
|
+
try {
|
|
96
|
+
const raw = await readFile(statePath(serverName), "utf8");
|
|
97
|
+
const parsed = JSON.parse(raw) as StoredState;
|
|
98
|
+
return {
|
|
99
|
+
clientInfo: parsed.clientInfo ?? undefined,
|
|
100
|
+
tokens: parsed.tokens ?? undefined,
|
|
101
|
+
codeVerifier: parsed.codeVerifier ?? undefined,
|
|
102
|
+
discoveryState: parsed.discoveryState ?? undefined,
|
|
103
|
+
};
|
|
104
|
+
} catch {
|
|
105
|
+
return {
|
|
106
|
+
clientInfo: undefined,
|
|
107
|
+
tokens: undefined,
|
|
108
|
+
codeVerifier: undefined,
|
|
109
|
+
discoveryState: undefined,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function saveState(serverName: string, state: StoredState): Promise<void> {
|
|
115
|
+
await mkdir(AUTH_DIR, { recursive: true });
|
|
116
|
+
// Only write defined fields
|
|
117
|
+
const toWrite: Record<string, unknown> = {};
|
|
118
|
+
if (state.clientInfo !== undefined) toWrite.clientInfo = state.clientInfo;
|
|
119
|
+
if (state.tokens !== undefined) toWrite.tokens = state.tokens;
|
|
120
|
+
if (state.codeVerifier !== undefined) toWrite.codeVerifier = state.codeVerifier;
|
|
121
|
+
if (state.discoveryState !== undefined) toWrite.discoveryState = state.discoveryState;
|
|
122
|
+
await writeFile(statePath(serverName), JSON.stringify(toWrite, null, 2), "utf8");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── OAuthClientProvider Implementation ────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
export class McpOAuthProvider implements OAuthClientProvider {
|
|
128
|
+
private serverName: string;
|
|
129
|
+
private authConfig: AuthConfig;
|
|
130
|
+
private _redirectUrl: string | undefined;
|
|
131
|
+
private _onAuthRequired: ((url: URL) => void | Promise<void>) | undefined;
|
|
132
|
+
private _oauthState: string | undefined;
|
|
133
|
+
|
|
134
|
+
constructor(
|
|
135
|
+
serverName: string,
|
|
136
|
+
authConfig: AuthConfig,
|
|
137
|
+
onAuthRequired?: (url: URL) => void | Promise<void>,
|
|
138
|
+
) {
|
|
139
|
+
this.serverName = serverName;
|
|
140
|
+
this.authConfig = authConfig;
|
|
141
|
+
this._redirectUrl = authConfig.redirectUrl;
|
|
142
|
+
this._onAuthRequired = onAuthRequired;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// --- redirectUrl ---
|
|
146
|
+
|
|
147
|
+
get redirectUrl(): string | URL {
|
|
148
|
+
// Use configured redirect URL if provided, otherwise use the callback server
|
|
149
|
+
// Use 127.0.0.1 explicitly (IPv4) to match the callback server binding
|
|
150
|
+
return this._redirectUrl || `http://127.0.0.1:${callbackPort}/callback`;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// --- clientMetadata ---
|
|
154
|
+
|
|
155
|
+
get clientMetadata(): OAuthClientMetadata {
|
|
156
|
+
const redirectUrl = String(this.redirectUrl);
|
|
157
|
+
return {
|
|
158
|
+
client_name: `pi-mcp/${this.serverName}`,
|
|
159
|
+
redirect_uris: [redirectUrl],
|
|
160
|
+
grant_types: ["authorization_code", "refresh_token"],
|
|
161
|
+
response_types: ["code"],
|
|
162
|
+
token_endpoint_auth_method: this.authConfig.clientSecret ? "client_secret_basic" : "none",
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// --- clientInformation ---
|
|
167
|
+
|
|
168
|
+
async clientInformation(): Promise<OAuthClientInformationMixed | undefined> {
|
|
169
|
+
// If static credentials provided, use those
|
|
170
|
+
if (this.authConfig.clientId) {
|
|
171
|
+
return {
|
|
172
|
+
client_id: this.authConfig.clientId,
|
|
173
|
+
...(this.authConfig.clientSecret && { client_secret: this.authConfig.clientSecret }),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
// Otherwise load from persisted DCR state
|
|
177
|
+
const state = await loadState(this.serverName);
|
|
178
|
+
if (state.clientInfo) {
|
|
179
|
+
return {
|
|
180
|
+
client_id: state.clientInfo.client_id,
|
|
181
|
+
...(state.clientInfo.client_secret && { client_secret: state.clientInfo.client_secret }),
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
return undefined;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// --- saveClientInformation (DCR) ---
|
|
188
|
+
|
|
189
|
+
async saveClientInformation(clientInformation: OAuthClientInformationMixed): Promise<void> {
|
|
190
|
+
const state = await loadState(this.serverName);
|
|
191
|
+
state.clientInfo = {
|
|
192
|
+
client_id: clientInformation.client_id,
|
|
193
|
+
client_secret: clientInformation.client_secret,
|
|
194
|
+
};
|
|
195
|
+
await saveState(this.serverName, state);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// --- tokens ---
|
|
199
|
+
|
|
200
|
+
async tokens(): Promise<OAuthTokens | undefined> {
|
|
201
|
+
const state = await loadState(this.serverName);
|
|
202
|
+
if (!state.tokens) return undefined;
|
|
203
|
+
|
|
204
|
+
// Always return stored tokens — even if expired.
|
|
205
|
+
// The SDK's auth() function checks tokens?.refresh_token and attempts
|
|
206
|
+
// silent refresh before falling back to a new authorization flow.
|
|
207
|
+
// Returning undefined for expired tokens would prevent that silent refresh
|
|
208
|
+
// and force the user to re-authenticate via browser every time.
|
|
209
|
+
//
|
|
210
|
+
// Flow when we return expired tokens:
|
|
211
|
+
// transport sends expired access_token → 401
|
|
212
|
+
// → auth() sees refresh_token → silent refresh → success → retry
|
|
213
|
+
//
|
|
214
|
+
// Flow when we return undefined (WRONG):
|
|
215
|
+
// transport has no token → auth() → no refresh possible → REDIRECT
|
|
216
|
+
// → user must re-authenticate in browser
|
|
217
|
+
|
|
218
|
+
// Build OAuthTokens — only include defined fields
|
|
219
|
+
const tokens: Record<string, string> = {
|
|
220
|
+
access_token: state.tokens.access_token,
|
|
221
|
+
};
|
|
222
|
+
if (state.tokens.token_type !== undefined) tokens.token_type = state.tokens.token_type;
|
|
223
|
+
if (state.tokens.refresh_token !== undefined) tokens.refresh_token = state.tokens.refresh_token;
|
|
224
|
+
return tokens as unknown as OAuthTokens;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// --- saveTokens ---
|
|
228
|
+
|
|
229
|
+
async saveTokens(tokens: OAuthTokens): Promise<void> {
|
|
230
|
+
const state = await loadState(this.serverName);
|
|
231
|
+
state.tokens = {
|
|
232
|
+
access_token: tokens.access_token,
|
|
233
|
+
token_type: tokens.token_type,
|
|
234
|
+
refresh_token: tokens.refresh_token,
|
|
235
|
+
expires_in: tokens.expires_in,
|
|
236
|
+
scope: tokens.scope,
|
|
237
|
+
saved_at: new Date().toISOString(),
|
|
238
|
+
};
|
|
239
|
+
await saveState(this.serverName, state);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- redirectToAuthorization ---
|
|
243
|
+
|
|
244
|
+
async redirectToAuthorization(authorizationUrl: URL): Promise<void> {
|
|
245
|
+
if (this._onAuthRequired) {
|
|
246
|
+
await this._onAuthRequired(authorizationUrl);
|
|
247
|
+
} else {
|
|
248
|
+
// Fallback: just log it
|
|
249
|
+
console.error(
|
|
250
|
+
`[pi-mcp] OAuth authorization required for "${this.serverName}".`,
|
|
251
|
+
`\n Open this URL in your browser: ${authorizationUrl.toString()}`,
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
// --- PKCE code verifier ---
|
|
257
|
+
|
|
258
|
+
async saveCodeVerifier(codeVerifier: string): Promise<void> {
|
|
259
|
+
const state = await loadState(this.serverName);
|
|
260
|
+
state.codeVerifier = codeVerifier;
|
|
261
|
+
await saveState(this.serverName, state);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async codeVerifier(): Promise<string> {
|
|
265
|
+
const state = await loadState(this.serverName);
|
|
266
|
+
if (!state.codeVerifier) {
|
|
267
|
+
throw new Error(`[pi-mcp] No PKCE code verifier found for "${this.serverName}"`);
|
|
268
|
+
}
|
|
269
|
+
return state.codeVerifier;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// --- Discovery state caching ---
|
|
273
|
+
|
|
274
|
+
async saveDiscoveryState(discState: OAuthDiscoveryState): Promise<void> {
|
|
275
|
+
const state = await loadState(this.serverName);
|
|
276
|
+
state.discoveryState = discState;
|
|
277
|
+
await saveState(this.serverName, state);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
async discoveryState(): Promise<OAuthDiscoveryState | undefined> {
|
|
281
|
+
const state = await loadState(this.serverName);
|
|
282
|
+
return state.discoveryState;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// --- OAuth state parameter (CSRF protection) ---
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Set the OAuth state parameter before calling auth().
|
|
289
|
+
* This should be called with a cryptographically random value.
|
|
290
|
+
*/
|
|
291
|
+
setState(state: string): void {
|
|
292
|
+
this._oauthState = state;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Returns the OAuth state parameter for CSRF protection.
|
|
297
|
+
* This is called by the SDK's auth() function when building the authorization URL.
|
|
298
|
+
* Returns empty string if no state has been set (no CSRF protection).
|
|
299
|
+
*/
|
|
300
|
+
async state(): Promise<string> {
|
|
301
|
+
return this._oauthState || "";
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// --- Credential invalidation ---
|
|
305
|
+
|
|
306
|
+
async invalidateCredentials(scope: "all" | "client" | "tokens" | "verifier" | "discovery"): Promise<void> {
|
|
307
|
+
const state = await loadState(this.serverName);
|
|
308
|
+
switch (scope) {
|
|
309
|
+
case "all":
|
|
310
|
+
state.clientInfo = undefined;
|
|
311
|
+
state.tokens = undefined;
|
|
312
|
+
state.codeVerifier = undefined;
|
|
313
|
+
state.discoveryState = undefined;
|
|
314
|
+
break;
|
|
315
|
+
case "client":
|
|
316
|
+
state.clientInfo = undefined;
|
|
317
|
+
break;
|
|
318
|
+
case "tokens":
|
|
319
|
+
state.tokens = undefined;
|
|
320
|
+
break;
|
|
321
|
+
case "verifier":
|
|
322
|
+
state.codeVerifier = undefined;
|
|
323
|
+
break;
|
|
324
|
+
case "discovery":
|
|
325
|
+
state.discoveryState = undefined;
|
|
326
|
+
break;
|
|
327
|
+
}
|
|
328
|
+
await saveState(this.serverName, state);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// ─── Public Helpers ────────────────────────────────────────────────────────────
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Get auth status info for a server — whether tokens exist, when they were saved, etc.
|
|
336
|
+
* Returns null if no auth state file exists at all.
|
|
337
|
+
*/
|
|
338
|
+
export async function getAuthStatus(serverName: string): Promise<{
|
|
339
|
+
hasTokens: boolean;
|
|
340
|
+
hasClientInfo: boolean;
|
|
341
|
+
savedAt: string | undefined;
|
|
342
|
+
scope: string | undefined;
|
|
343
|
+
} | null> {
|
|
344
|
+
const state = await loadState(serverName);
|
|
345
|
+
if (
|
|
346
|
+
state.clientInfo === undefined &&
|
|
347
|
+
state.tokens === undefined &&
|
|
348
|
+
state.codeVerifier === undefined &&
|
|
349
|
+
state.discoveryState === undefined
|
|
350
|
+
) {
|
|
351
|
+
return null;
|
|
352
|
+
}
|
|
353
|
+
return {
|
|
354
|
+
hasTokens: state.tokens !== undefined,
|
|
355
|
+
hasClientInfo: state.clientInfo !== undefined,
|
|
356
|
+
savedAt: state.tokens?.saved_at,
|
|
357
|
+
scope: state.tokens?.scope,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Reset all OAuth state for a server (tokens, client info, PKCE verifier, discovery).
|
|
363
|
+
* Used to force re-authorization on next connection.
|
|
364
|
+
*/
|
|
365
|
+
export async function resetAuth(serverName: string): Promise<void> {
|
|
366
|
+
await unlink(statePath(serverName)).catch(() => {});
|
|
367
|
+
}
|
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));
|