pi-soly 1.9.3 → 1.11.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/ask/index.ts +12 -11
- package/ask/picker.ts +356 -76
- package/ask/prompt.ts +6 -2
- package/ask/tests/picker.test.ts +273 -82
- package/codemap.ts +276 -0
- package/hotreload.ts +239 -0
- package/init.ts +302 -0
- package/mcp/CHANGELOG.md +384 -0
- package/mcp/LICENSE +21 -0
- package/mcp/OAUTH.md +355 -0
- package/mcp/README.md +410 -0
- package/mcp/__tests__/agent-dir-paths.upstream-test.ts +80 -0
- package/mcp/__tests__/cli.upstream-test.ts +97 -0
- package/mcp/__tests__/commands-onboarding.upstream-test.ts +157 -0
- package/mcp/__tests__/config.upstream-test.ts +303 -0
- package/mcp/__tests__/consent-manager.upstream-test.ts +151 -0
- package/mcp/__tests__/direct-tools-auto-auth.upstream-test.ts +218 -0
- package/mcp/__tests__/direct-tools.upstream-test.ts +299 -0
- package/mcp/__tests__/elicitation-handler.upstream-test.ts +346 -0
- package/mcp/__tests__/elicitation-sdk-integration.upstream-test.ts +140 -0
- package/mcp/__tests__/errors.upstream-test.ts +218 -0
- package/mcp/__tests__/fixtures/elicitation-server.mjs +98 -0
- package/mcp/__tests__/host-html-template.upstream-test.ts +278 -0
- package/mcp/__tests__/index-lifecycle.upstream-test.ts +484 -0
- package/mcp/__tests__/init-elicitation.upstream-test.ts +86 -0
- package/mcp/__tests__/interactive-visualizer-server.upstream-test.ts +28 -0
- package/mcp/__tests__/logger.upstream-test.ts +175 -0
- package/mcp/__tests__/mcp-auth-flow-client-credentials.upstream-test.ts +612 -0
- package/mcp/__tests__/mcp-auth-storage.upstream-test.ts +47 -0
- package/mcp/__tests__/mcp-callback-server-unref.upstream-test.ts +264 -0
- package/mcp/__tests__/mcp-oauth-provider.upstream-test.ts +216 -0
- package/mcp/__tests__/mcp-panel-auth.upstream-test.ts +206 -0
- package/mcp/__tests__/mcp-panel-exclude-tools.upstream-test.ts +69 -0
- package/mcp/__tests__/mcp-panel-keybindings.upstream-test.ts +186 -0
- package/mcp/__tests__/npx-resolver.upstream-test.ts +54 -0
- package/mcp/__tests__/oauth-handler.upstream-test.ts +77 -0
- package/mcp/__tests__/onboarding-state.upstream-test.ts +52 -0
- package/mcp/__tests__/package-manifest.upstream-test.ts +22 -0
- package/mcp/__tests__/proxy-modes-auto-auth.upstream-test.ts +253 -0
- package/mcp/__tests__/proxy-modes-discovery.upstream-test.ts +84 -0
- package/mcp/__tests__/proxy-modes-manual-auth.upstream-test.ts +91 -0
- package/mcp/__tests__/proxy-modes-ui-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/sampling-handler.upstream-test.ts +285 -0
- package/mcp/__tests__/server-manager-http-auth.upstream-test.ts +144 -0
- package/mcp/__tests__/server-manager-sampling.upstream-test.ts +236 -0
- package/mcp/__tests__/tool-metadata.upstream-test.ts +111 -0
- package/mcp/__tests__/tool-result-renderer.upstream-test.ts +147 -0
- package/mcp/__tests__/ui-integration.upstream-test.ts +551 -0
- package/mcp/__tests__/ui-resource-handler.upstream-test.ts +303 -0
- package/mcp/__tests__/ui-server.upstream-test.ts +967 -0
- package/mcp/__tests__/ui-session-messages.upstream-test.ts +72 -0
- package/mcp/__tests__/ui-streaming.upstream-test.ts +543 -0
- package/mcp/agent-dir.ts +20 -0
- package/mcp/app-bridge.bundle.js +67 -0
- package/mcp/cli.js +184 -0
- package/mcp/commands.ts +422 -0
- package/mcp/config.ts +666 -0
- package/mcp/consent-manager.ts +64 -0
- package/mcp/direct-tools.ts +439 -0
- package/mcp/elicitation-handler.ts +347 -0
- package/mcp/errors.ts +219 -0
- package/mcp/glimpse-ui.ts +80 -0
- package/mcp/host-html-template.ts +427 -0
- package/mcp/index.ts +362 -0
- package/mcp/init.ts +362 -0
- package/mcp/lifecycle.ts +93 -0
- package/mcp/logger.ts +169 -0
- package/mcp/mcp-auth-flow.ts +559 -0
- package/mcp/mcp-auth-flow.upstream-test.ts +259 -0
- package/mcp/mcp-auth.ts +302 -0
- package/mcp/mcp-auth.upstream-test.ts +373 -0
- package/mcp/mcp-callback-server.ts +372 -0
- package/mcp/mcp-callback-server.upstream-test.ts +416 -0
- package/mcp/mcp-oauth-provider.ts +369 -0
- package/mcp/mcp-oauth-provider.upstream-test.ts +518 -0
- package/mcp/mcp-panel.ts +829 -0
- package/mcp/mcp-setup-panel.ts +580 -0
- package/mcp/metadata-cache.ts +201 -0
- package/mcp/notify.ts +111 -0
- package/mcp/npx-resolver.ts +424 -0
- package/mcp/oauth-handler.ts +57 -0
- package/mcp/onboarding-state.ts +68 -0
- package/mcp/package.json +106 -0
- package/mcp/panel-keys.ts +37 -0
- package/mcp/proxy-modes.ts +949 -0
- package/mcp/resource-tools.ts +17 -0
- package/mcp/sampling-handler.ts +268 -0
- package/mcp/server-manager.ts +545 -0
- package/mcp/state.ts +41 -0
- package/mcp/tool-metadata.ts +216 -0
- package/mcp/tool-registrar.ts +46 -0
- package/mcp/tool-result-renderer.ts +161 -0
- package/mcp/types.ts +448 -0
- package/mcp/ui-resource-handler.ts +146 -0
- package/mcp/ui-server.ts +623 -0
- package/mcp/ui-session.ts +386 -0
- package/mcp/ui-stream-types.ts +89 -0
- package/mcp/utils.ts +129 -0
- package/mcp/vitest.config.ts +14 -0
- package/migrate.ts +258 -0
- package/notification.ts +218 -0
- package/notifications-log.ts +83 -0
- package/package.json +20 -3
- package/status.ts +140 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
type MockServer = {
|
|
4
|
+
once: ReturnType<typeof vi.fn>;
|
|
5
|
+
listen: ReturnType<typeof vi.fn>;
|
|
6
|
+
close: ReturnType<typeof vi.fn>;
|
|
7
|
+
unref: ReturnType<typeof vi.fn>;
|
|
8
|
+
address: ReturnType<typeof vi.fn>;
|
|
9
|
+
handlers: Map<string, (error?: NodeJS.ErrnoException) => void>;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
const mocks = vi.hoisted(() => {
|
|
13
|
+
const state = {
|
|
14
|
+
configuredPort: 4337,
|
|
15
|
+
activePort: 4337,
|
|
16
|
+
callbackPath: "/callback",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const runtime = {
|
|
20
|
+
assignedPort: 4338,
|
|
21
|
+
listenImpl: (
|
|
22
|
+
_server: MockServer,
|
|
23
|
+
_port: number,
|
|
24
|
+
_host: string,
|
|
25
|
+
onListen: () => void,
|
|
26
|
+
_handlers: Map<string, (error?: NodeJS.ErrnoException) => void>
|
|
27
|
+
) => {
|
|
28
|
+
onListen();
|
|
29
|
+
},
|
|
30
|
+
servers: [] as MockServer[],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const createServer = vi.fn((_handler: unknown) => {
|
|
34
|
+
const handlers = new Map<string, (error?: NodeJS.ErrnoException) => void>();
|
|
35
|
+
const server: MockServer = {
|
|
36
|
+
handlers,
|
|
37
|
+
once: vi.fn((event: string, handler: (error?: NodeJS.ErrnoException) => void) => {
|
|
38
|
+
handlers.set(event, handler);
|
|
39
|
+
return server;
|
|
40
|
+
}),
|
|
41
|
+
listen: vi.fn((port: number, host: string, onListen: () => void) => {
|
|
42
|
+
runtime.listenImpl(server, port, host, onListen, handlers);
|
|
43
|
+
}),
|
|
44
|
+
close: vi.fn((cb?: () => void) => cb?.()),
|
|
45
|
+
unref: vi.fn(),
|
|
46
|
+
address: vi.fn(() => ({ address: "127.0.0.1", family: "IPv4", port: runtime.assignedPort })),
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
runtime.servers.push(server);
|
|
50
|
+
return server;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
state,
|
|
55
|
+
runtime,
|
|
56
|
+
createServer,
|
|
57
|
+
getConfiguredOAuthCallbackPort: vi.fn(() => state.configuredPort),
|
|
58
|
+
getOAuthCallbackPort: vi.fn(() => state.activePort),
|
|
59
|
+
getOAuthCallbackPath: vi.fn(() => state.callbackPath),
|
|
60
|
+
setOAuthCallbackPath: vi.fn((path: string) => {
|
|
61
|
+
state.callbackPath = path.startsWith("/") ? path : `/${path}`;
|
|
62
|
+
}),
|
|
63
|
+
setOAuthCallbackPort: vi.fn((port: number) => {
|
|
64
|
+
state.activePort = port;
|
|
65
|
+
}),
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
vi.mock("http", () => ({
|
|
70
|
+
createServer: mocks.createServer,
|
|
71
|
+
}));
|
|
72
|
+
|
|
73
|
+
vi.mock("../mcp-oauth-provider.ts", () => ({
|
|
74
|
+
DEFAULT_OAUTH_CALLBACK_PATH: "/callback",
|
|
75
|
+
getConfiguredOAuthCallbackPort: mocks.getConfiguredOAuthCallbackPort,
|
|
76
|
+
getOAuthCallbackPath: mocks.getOAuthCallbackPath,
|
|
77
|
+
getOAuthCallbackPort: mocks.getOAuthCallbackPort,
|
|
78
|
+
setOAuthCallbackPath: mocks.setOAuthCallbackPath,
|
|
79
|
+
setOAuthCallbackPort: mocks.setOAuthCallbackPort,
|
|
80
|
+
}));
|
|
81
|
+
|
|
82
|
+
describe("mcp-callback-server", () => {
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
vi.resetModules();
|
|
85
|
+
mocks.state.configuredPort = 4337;
|
|
86
|
+
mocks.state.activePort = 4337;
|
|
87
|
+
mocks.state.callbackPath = "/callback";
|
|
88
|
+
mocks.runtime.assignedPort = 4338;
|
|
89
|
+
mocks.runtime.servers = [];
|
|
90
|
+
mocks.runtime.listenImpl = (_server, _port, _host, onListen) => {
|
|
91
|
+
onListen();
|
|
92
|
+
};
|
|
93
|
+
mocks.createServer.mockClear();
|
|
94
|
+
mocks.getConfiguredOAuthCallbackPort.mockClear();
|
|
95
|
+
mocks.getOAuthCallbackPath.mockClear();
|
|
96
|
+
mocks.getOAuthCallbackPort.mockClear();
|
|
97
|
+
mocks.setOAuthCallbackPath.mockClear();
|
|
98
|
+
mocks.setOAuthCallbackPort.mockClear();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("binds localhost on an OS-assigned port and unrefs after a successful non-strict bind", async () => {
|
|
102
|
+
const { ensureCallbackServer } = await import("../mcp-callback-server.ts");
|
|
103
|
+
|
|
104
|
+
await ensureCallbackServer();
|
|
105
|
+
|
|
106
|
+
expect(mocks.runtime.servers[0]?.listen).toHaveBeenCalledWith(0, "localhost", expect.any(Function));
|
|
107
|
+
expect(mocks.runtime.servers[0]?.unref).toHaveBeenCalledTimes(1);
|
|
108
|
+
expect(mocks.state.activePort).toBe(4338);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("binds the configured localhost port exactly in strict mode", async () => {
|
|
112
|
+
const { ensureCallbackServer } = await import("../mcp-callback-server.ts");
|
|
113
|
+
|
|
114
|
+
await ensureCallbackServer({ strictPort: true });
|
|
115
|
+
|
|
116
|
+
expect(mocks.runtime.servers[0]?.listen).toHaveBeenCalledWith(4337, "localhost", expect.any(Function));
|
|
117
|
+
expect(mocks.runtime.servers[0]?.listen).not.toHaveBeenCalledWith(0, "localhost", expect.any(Function));
|
|
118
|
+
expect(mocks.state.activePort).toBe(4337);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it("binds an explicit loopback host and port exactly in strict mode", async () => {
|
|
122
|
+
const { ensureCallbackServer } = await import("../mcp-callback-server.ts");
|
|
123
|
+
|
|
124
|
+
await ensureCallbackServer({ strictPort: true, port: 3118, callbackHost: "127.0.0.1", callbackPath: "/custom/callback" });
|
|
125
|
+
|
|
126
|
+
expect(mocks.runtime.servers[0]?.listen).toHaveBeenCalledWith(3118, "127.0.0.1", expect.any(Function));
|
|
127
|
+
expect(mocks.runtime.servers[0]?.listen).not.toHaveBeenCalledWith(0, "127.0.0.1", expect.any(Function));
|
|
128
|
+
expect(mocks.state.activePort).toBe(3118);
|
|
129
|
+
expect(mocks.state.callbackPath).toBe("/custom/callback");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("does not unref when bind fails", async () => {
|
|
133
|
+
mocks.runtime.listenImpl = (_server, _port, _host, _onListen, handlers) => {
|
|
134
|
+
Promise.resolve().then(() => {
|
|
135
|
+
handlers.get("error")?.(Object.assign(new Error("EADDRINUSE"), { code: "EADDRINUSE" }));
|
|
136
|
+
});
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const { ensureCallbackServer } = await import("../mcp-callback-server.ts");
|
|
140
|
+
|
|
141
|
+
await expect(ensureCallbackServer({ strictPort: true })).rejects.toThrow(/already in use/);
|
|
142
|
+
expect(mocks.runtime.servers[0]?.unref).not.toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it("serializes concurrent callback server startup", async () => {
|
|
146
|
+
let resolveListen: (() => void) | undefined;
|
|
147
|
+
mocks.runtime.listenImpl = (_server, _port, _host, onListen) => {
|
|
148
|
+
resolveListen = onListen;
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
const { ensureCallbackServer } = await import("../mcp-callback-server.ts");
|
|
152
|
+
|
|
153
|
+
const first = ensureCallbackServer();
|
|
154
|
+
const second = ensureCallbackServer();
|
|
155
|
+
expect(mocks.runtime.servers).toHaveLength(1);
|
|
156
|
+
|
|
157
|
+
resolveListen?.();
|
|
158
|
+
await Promise.all([first, second]);
|
|
159
|
+
|
|
160
|
+
expect(mocks.runtime.servers).toHaveLength(1);
|
|
161
|
+
expect(mocks.runtime.servers[0]?.unref).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(mocks.state.activePort).toBe(4338);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("rebinds to the configured port when strict mode is requested", async () => {
|
|
166
|
+
const { ensureCallbackServer } = await import("../mcp-callback-server.ts");
|
|
167
|
+
|
|
168
|
+
await ensureCallbackServer();
|
|
169
|
+
expect(mocks.state.activePort).toBe(4338);
|
|
170
|
+
|
|
171
|
+
await ensureCallbackServer({ strictPort: true });
|
|
172
|
+
|
|
173
|
+
expect(mocks.runtime.servers[0]?.close).toHaveBeenCalledTimes(1);
|
|
174
|
+
expect(mocks.runtime.servers[1]?.listen).toHaveBeenCalledWith(4337, "localhost", expect.any(Function));
|
|
175
|
+
expect(mocks.state.activePort).toBe(4337);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("keeps the existing callback server when strict rebind fails", async () => {
|
|
179
|
+
const { ensureCallbackServer, isCallbackServerRunning } = await import("../mcp-callback-server.ts");
|
|
180
|
+
|
|
181
|
+
await ensureCallbackServer();
|
|
182
|
+
expect(mocks.state.activePort).toBe(4338);
|
|
183
|
+
|
|
184
|
+
mocks.runtime.listenImpl = (_server, port, _host, onListen, handlers) => {
|
|
185
|
+
if (port === mocks.state.configuredPort) {
|
|
186
|
+
Promise.resolve().then(() => {
|
|
187
|
+
handlers.get("error")?.(Object.assign(new Error("EADDRINUSE"), { code: "EADDRINUSE" }));
|
|
188
|
+
});
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
onListen();
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
await expect(ensureCallbackServer({ strictPort: true })).rejects.toThrow(/already in use/);
|
|
196
|
+
|
|
197
|
+
expect(isCallbackServerRunning()).toBe(true);
|
|
198
|
+
expect(mocks.runtime.servers[0]?.close).not.toHaveBeenCalled();
|
|
199
|
+
expect(mocks.state.activePort).toBe(4338);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it("does not switch ports in strict mode while an authorization URL can reference the active port", async () => {
|
|
203
|
+
const {
|
|
204
|
+
ensureCallbackServer,
|
|
205
|
+
reserveCallbackServer,
|
|
206
|
+
releaseCallbackServer,
|
|
207
|
+
} = await import("../mcp-callback-server.ts");
|
|
208
|
+
|
|
209
|
+
await ensureCallbackServer();
|
|
210
|
+
reserveCallbackServer("reserved-state");
|
|
211
|
+
|
|
212
|
+
await expect(ensureCallbackServer({ strictPort: true })).rejects.toThrow(/cannot be switched while authorizations are pending/);
|
|
213
|
+
expect(mocks.runtime.servers).toHaveLength(1);
|
|
214
|
+
|
|
215
|
+
releaseCallbackServer("reserved-state");
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("reserves callback state inside ensureCallbackServer before releasing the startup lock", async () => {
|
|
219
|
+
const {
|
|
220
|
+
ensureCallbackServer,
|
|
221
|
+
releaseCallbackServer,
|
|
222
|
+
} = await import("../mcp-callback-server.ts");
|
|
223
|
+
|
|
224
|
+
await ensureCallbackServer({ oauthState: "atomic-reserved-state", reserveState: true });
|
|
225
|
+
|
|
226
|
+
await expect(ensureCallbackServer({ strictPort: true })).rejects.toThrow(/cannot be switched while authorizations are pending/);
|
|
227
|
+
expect(mocks.runtime.servers).toHaveLength(1);
|
|
228
|
+
|
|
229
|
+
releaseCallbackServer("atomic-reserved-state");
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("does not switch host or path while callback state reserved by ensureCallbackServer", async () => {
|
|
233
|
+
const {
|
|
234
|
+
ensureCallbackServer,
|
|
235
|
+
releaseCallbackServer,
|
|
236
|
+
} = await import("../mcp-callback-server.ts");
|
|
237
|
+
|
|
238
|
+
await ensureCallbackServer({ callbackPath: "/first/callback", oauthState: "reserved-endpoint-state", reserveState: true });
|
|
239
|
+
|
|
240
|
+
await expect(ensureCallbackServer({ callbackHost: "127.0.0.1" })).rejects.toThrow(/cannot be switched while authorizations are pending/);
|
|
241
|
+
await expect(ensureCallbackServer({ callbackPath: "/second/callback" })).rejects.toThrow(/cannot be switched while authorizations are pending/);
|
|
242
|
+
expect(mocks.runtime.servers).toHaveLength(1);
|
|
243
|
+
expect(mocks.state.callbackPath).toBe("/first/callback");
|
|
244
|
+
|
|
245
|
+
releaseCallbackServer("reserved-endpoint-state");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
it("does not switch ports in strict mode while callbacks are pending", async () => {
|
|
249
|
+
const {
|
|
250
|
+
ensureCallbackServer,
|
|
251
|
+
waitForCallback,
|
|
252
|
+
cancelPendingCallback,
|
|
253
|
+
} = await import("../mcp-callback-server.ts");
|
|
254
|
+
|
|
255
|
+
await ensureCallbackServer();
|
|
256
|
+
const pending = waitForCallback("pending-state");
|
|
257
|
+
|
|
258
|
+
await expect(ensureCallbackServer({ strictPort: true })).rejects.toThrow(/cannot be switched while authorizations are pending/);
|
|
259
|
+
expect(mocks.runtime.servers).toHaveLength(1);
|
|
260
|
+
|
|
261
|
+
cancelPendingCallback("pending-state");
|
|
262
|
+
await expect(pending).rejects.toThrow(/Authorization cancelled/);
|
|
263
|
+
});
|
|
264
|
+
});
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js";
|
|
6
|
+
import { McpOAuthProvider } from "../mcp-oauth-provider.ts";
|
|
7
|
+
import { saveAuthEntry, updateOAuthState } from "../mcp-auth.ts";
|
|
8
|
+
|
|
9
|
+
describe("McpOAuthProvider clientMetadata scope", () => {
|
|
10
|
+
it("includes configured scope in authorization_code client metadata", () => {
|
|
11
|
+
const provider = new McpOAuthProvider(
|
|
12
|
+
"scope-test",
|
|
13
|
+
"https://api.example.com/mcp",
|
|
14
|
+
{ scope: "api://resource/.default openid" },
|
|
15
|
+
{ onRedirect: async () => {} },
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
expect(provider.clientMetadata.scope).toBe("api://resource/.default openid");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("omits scope from client metadata when not configured", () => {
|
|
22
|
+
const provider = new McpOAuthProvider(
|
|
23
|
+
"no-scope-test",
|
|
24
|
+
"https://api.example.com/mcp",
|
|
25
|
+
{},
|
|
26
|
+
{ onRedirect: async () => {} },
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
expect(provider.clientMetadata).not.toHaveProperty("scope");
|
|
30
|
+
});
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe("McpOAuthProvider addClientAuthentication", () => {
|
|
34
|
+
const originalOAuthDir = process.env.MCP_OAUTH_DIR;
|
|
35
|
+
const serverUrl = "https://api.example.com/mcp";
|
|
36
|
+
let authDir: string;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
authDir = mkdtempSync(join(tmpdir(), "pi-mcp-oauth-auth-"));
|
|
40
|
+
process.env.MCP_OAUTH_DIR = authDir;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
rmSync(authDir, { recursive: true, force: true });
|
|
45
|
+
if (originalOAuthDir === undefined) {
|
|
46
|
+
delete process.env.MCP_OAUTH_DIR;
|
|
47
|
+
} else {
|
|
48
|
+
process.env.MCP_OAUTH_DIR = originalOAuthDir;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("adds configured scope to authorization_code token params", async () => {
|
|
53
|
+
const provider = new McpOAuthProvider(
|
|
54
|
+
"auth-scope",
|
|
55
|
+
serverUrl,
|
|
56
|
+
{ clientId: "my-client", scope: "api://res/.default" },
|
|
57
|
+
{ onRedirect: async () => {} },
|
|
58
|
+
);
|
|
59
|
+
const params = new URLSearchParams({ grant_type: "authorization_code", code: "abc" });
|
|
60
|
+
|
|
61
|
+
await provider.addClientAuthentication(new Headers(), params, new URL("https://auth.example.com/token"));
|
|
62
|
+
|
|
63
|
+
expect(params.get("scope")).toBe("api://res/.default");
|
|
64
|
+
expect(params.get("client_id")).toBe("my-client");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("uses client_secret_basic when the token endpoint only supports basic auth", async () => {
|
|
68
|
+
const provider = new McpOAuthProvider(
|
|
69
|
+
"auth-basic",
|
|
70
|
+
serverUrl,
|
|
71
|
+
{ clientId: "my-client", clientSecret: "my-secret", scope: "api://res/.default" },
|
|
72
|
+
{ onRedirect: async () => {} },
|
|
73
|
+
);
|
|
74
|
+
const headers = new Headers();
|
|
75
|
+
const params = new URLSearchParams({ grant_type: "authorization_code", code: "abc" });
|
|
76
|
+
|
|
77
|
+
await provider.addClientAuthentication(headers, params, new URL("https://auth.example.com/token"), {
|
|
78
|
+
issuer: "https://auth.example.com",
|
|
79
|
+
authorization_endpoint: "https://auth.example.com/authorize",
|
|
80
|
+
token_endpoint: "https://auth.example.com/token",
|
|
81
|
+
response_types_supported: ["code"],
|
|
82
|
+
grant_types_supported: ["authorization_code"],
|
|
83
|
+
token_endpoint_auth_methods_supported: ["client_secret_basic"],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
expect(headers.get("Authorization")).toBe(`Basic ${Buffer.from("my-client:my-secret").toString("base64")}`);
|
|
87
|
+
expect(params.get("scope")).toBe("api://res/.default");
|
|
88
|
+
expect(params.has("client_id")).toBe(false);
|
|
89
|
+
expect(params.has("client_secret")).toBe(false);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("uses client_secret_post when metadata is absent", async () => {
|
|
93
|
+
const provider = new McpOAuthProvider(
|
|
94
|
+
"auth-post",
|
|
95
|
+
serverUrl,
|
|
96
|
+
{ clientId: "my-client", clientSecret: "my-secret" },
|
|
97
|
+
{ onRedirect: async () => {} },
|
|
98
|
+
);
|
|
99
|
+
const headers = new Headers();
|
|
100
|
+
const params = new URLSearchParams({ grant_type: "authorization_code", code: "abc" });
|
|
101
|
+
|
|
102
|
+
await provider.addClientAuthentication(headers, params, new URL("https://auth.example.com/token"));
|
|
103
|
+
|
|
104
|
+
expect(headers.has("Authorization")).toBe(false);
|
|
105
|
+
expect(params.get("client_id")).toBe("my-client");
|
|
106
|
+
expect(params.get("client_secret")).toBe("my-secret");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("does not overwrite token params that are already present", async () => {
|
|
110
|
+
const provider = new McpOAuthProvider(
|
|
111
|
+
"auth-no-overwrite",
|
|
112
|
+
serverUrl,
|
|
113
|
+
{ clientId: "my-client", clientSecret: "my-secret", scope: "api://res/.default" },
|
|
114
|
+
{ onRedirect: async () => {} },
|
|
115
|
+
);
|
|
116
|
+
const params = new URLSearchParams({
|
|
117
|
+
grant_type: "authorization_code",
|
|
118
|
+
scope: "already-set",
|
|
119
|
+
client_id: "already-set-id",
|
|
120
|
+
client_secret: "already-set-secret",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
await provider.addClientAuthentication(new Headers(), params, new URL("https://auth.example.com/token"));
|
|
124
|
+
|
|
125
|
+
expect(params.get("scope")).toBe("already-set");
|
|
126
|
+
expect(params.get("client_id")).toBe("already-set-id");
|
|
127
|
+
expect(params.get("client_secret")).toBe("already-set-secret");
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("does not add scope to refresh token requests", async () => {
|
|
131
|
+
const provider = new McpOAuthProvider(
|
|
132
|
+
"auth-refresh",
|
|
133
|
+
serverUrl,
|
|
134
|
+
{ clientId: "my-client", scope: "api://res/.default" },
|
|
135
|
+
{ onRedirect: async () => {} },
|
|
136
|
+
);
|
|
137
|
+
const params = new URLSearchParams({ grant_type: "refresh_token", refresh_token: "refresh" });
|
|
138
|
+
|
|
139
|
+
await provider.addClientAuthentication(new Headers(), params, new URL("https://auth.example.com/token"));
|
|
140
|
+
|
|
141
|
+
expect(params.has("scope")).toBe(false);
|
|
142
|
+
expect(params.get("client_id")).toBe("my-client");
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
describe("McpOAuthProvider authorization fallback", () => {
|
|
147
|
+
const originalOAuthDir = process.env.MCP_OAUTH_DIR;
|
|
148
|
+
const serverUrl = "https://api.example.com/mcp";
|
|
149
|
+
let authDir: string;
|
|
150
|
+
|
|
151
|
+
beforeEach(() => {
|
|
152
|
+
authDir = mkdtempSync(join(tmpdir(), "pi-mcp-oauth-provider-"));
|
|
153
|
+
process.env.MCP_OAUTH_DIR = authDir;
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
afterEach(() => {
|
|
157
|
+
rmSync(authDir, { recursive: true, force: true });
|
|
158
|
+
if (originalOAuthDir === undefined) {
|
|
159
|
+
delete process.env.MCP_OAUTH_DIR;
|
|
160
|
+
} else {
|
|
161
|
+
process.env.MCP_OAUTH_DIR = originalOAuthDir;
|
|
162
|
+
}
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("throws UnauthorizedError when state is requested outside a user-initiated flow", async () => {
|
|
166
|
+
const provider = new McpOAuthProvider("state-missing", serverUrl, {}, { onRedirect: async () => {} });
|
|
167
|
+
|
|
168
|
+
await expect(provider.state()).rejects.toBeInstanceOf(UnauthorizedError);
|
|
169
|
+
await expect(provider.state()).rejects.toThrow(/Re-authentication required/);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("throws UnauthorizedError before redirecting when no OAuth flow is in progress", async () => {
|
|
173
|
+
let redirected = false;
|
|
174
|
+
const provider = new McpOAuthProvider("redirect-missing", serverUrl, {}, {
|
|
175
|
+
onRedirect: async () => {
|
|
176
|
+
redirected = true;
|
|
177
|
+
},
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
await expect(provider.redirectToAuthorization(new URL("https://auth.example.com/authorize")))
|
|
181
|
+
.rejects.toBeInstanceOf(UnauthorizedError);
|
|
182
|
+
expect(redirected).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it("still redirects when startAuth has seeded OAuth state", async () => {
|
|
186
|
+
const authUrl = new URL("https://auth.example.com/authorize");
|
|
187
|
+
let redirected: URL | undefined;
|
|
188
|
+
updateOAuthState("redirect-active", "state-abc", serverUrl);
|
|
189
|
+
const provider = new McpOAuthProvider("redirect-active", serverUrl, {}, {
|
|
190
|
+
onRedirect: async (url) => {
|
|
191
|
+
redirected = url;
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await provider.redirectToAuthorization(authUrl);
|
|
196
|
+
|
|
197
|
+
expect(redirected).toBe(authUrl);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it("throws before redirecting when only stale URL-bound state exists", async () => {
|
|
201
|
+
let redirected = false;
|
|
202
|
+
saveAuthEntry("redirect-stale-url", {
|
|
203
|
+
oauthState: "state-abc",
|
|
204
|
+
serverUrl: "https://old.example.com/mcp",
|
|
205
|
+
}, "https://old.example.com/mcp");
|
|
206
|
+
const provider = new McpOAuthProvider("redirect-stale-url", serverUrl, {}, {
|
|
207
|
+
onRedirect: async () => {
|
|
208
|
+
redirected = true;
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
await expect(provider.redirectToAuthorization(new URL("https://auth.example.com/authorize")))
|
|
213
|
+
.rejects.toBeInstanceOf(UnauthorizedError);
|
|
214
|
+
expect(redirected).toBe(false);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { createMcpPanel } from "../mcp-panel.ts";
|
|
3
|
+
import { computeServerHash, type MetadataCache } from "../metadata-cache.ts";
|
|
4
|
+
import type { McpConfig, McpPanelCallbacks } from "../types.ts";
|
|
5
|
+
|
|
6
|
+
function stripAnsi(input: string): string {
|
|
7
|
+
return input.replace(/\x1b\[[0-9;]*m/g, "");
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function createCache(config: McpConfig): MetadataCache {
|
|
11
|
+
return {
|
|
12
|
+
version: 1,
|
|
13
|
+
servers: {
|
|
14
|
+
github: {
|
|
15
|
+
configHash: computeServerHash(config.mcpServers.github),
|
|
16
|
+
cachedAt: Date.now(),
|
|
17
|
+
tools: [{ name: "search", description: "Search" }],
|
|
18
|
+
resources: [],
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function createCallbacks(status: "connected" | "idle" | "failed" | "needs-auth" = "needs-auth") {
|
|
25
|
+
let currentStatus = status;
|
|
26
|
+
const callbacks: McpPanelCallbacks = {
|
|
27
|
+
reconnect: async () => true,
|
|
28
|
+
canAuthenticate: (serverName) => serverName === "github",
|
|
29
|
+
authenticate: vi.fn(async () => {
|
|
30
|
+
currentStatus = "idle";
|
|
31
|
+
return { ok: true };
|
|
32
|
+
}),
|
|
33
|
+
getConnectionStatus: () => currentStatus,
|
|
34
|
+
refreshCacheAfterReconnect: () => null,
|
|
35
|
+
};
|
|
36
|
+
return callbacks;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function deferred<T>() {
|
|
40
|
+
let resolve!: (value: T) => void;
|
|
41
|
+
const promise = new Promise<T>((res) => {
|
|
42
|
+
resolve = res;
|
|
43
|
+
});
|
|
44
|
+
return { promise, resolve };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("mcp-panel auth actions", () => {
|
|
48
|
+
it("authenticates a needs-auth server when pressing enter", async () => {
|
|
49
|
+
const config: McpConfig = {
|
|
50
|
+
mcpServers: {
|
|
51
|
+
github: { url: "https://api.githubcopilot.com/mcp", auth: "oauth" },
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
const callbacks = createCallbacks("needs-auth");
|
|
55
|
+
const tui = { requestRender: vi.fn() };
|
|
56
|
+
const panel = createMcpPanel(config, createCache(config), new Map(), callbacks, tui, () => {});
|
|
57
|
+
|
|
58
|
+
panel.handleInput("\r");
|
|
59
|
+
await Promise.resolve();
|
|
60
|
+
|
|
61
|
+
expect(callbacks.authenticate).toHaveBeenCalledWith("github");
|
|
62
|
+
const output = stripAnsi(panel.render(100).join("\n"));
|
|
63
|
+
expect(output).toContain("OAuth finished for github");
|
|
64
|
+
panel.dispose();
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("authenticates OAuth-capable idle servers with ctrl+a", async () => {
|
|
68
|
+
const config: McpConfig = {
|
|
69
|
+
mcpServers: {
|
|
70
|
+
github: { url: "https://api.githubcopilot.com/mcp", auth: "oauth" },
|
|
71
|
+
},
|
|
72
|
+
};
|
|
73
|
+
const callbacks = createCallbacks("idle");
|
|
74
|
+
const panel = createMcpPanel(config, createCache(config), new Map(), callbacks, { requestRender: () => {} }, () => {});
|
|
75
|
+
|
|
76
|
+
panel.handleInput("\x01");
|
|
77
|
+
await Promise.resolve();
|
|
78
|
+
|
|
79
|
+
expect(callbacks.authenticate).toHaveBeenCalledWith("github");
|
|
80
|
+
panel.dispose();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("shows concrete auth failure messages in the panel", async () => {
|
|
84
|
+
const config: McpConfig = {
|
|
85
|
+
mcpServers: {
|
|
86
|
+
github: { url: "https://api.githubcopilot.com/mcp", auth: "oauth" },
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
const callbacks = createCallbacks("needs-auth");
|
|
90
|
+
callbacks.authenticate = vi.fn(async () => ({ ok: false, message: "browser launch failed" }));
|
|
91
|
+
const panel = createMcpPanel(config, createCache(config), new Map(), callbacks, { requestRender: () => {} }, () => {});
|
|
92
|
+
|
|
93
|
+
panel.handleInput("\r");
|
|
94
|
+
await Promise.resolve();
|
|
95
|
+
|
|
96
|
+
const output = stripAnsi(panel.render(100).join("\n"));
|
|
97
|
+
expect(output).toContain("OAuth failed for github: browser launch failed");
|
|
98
|
+
panel.dispose();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("does not start duplicate auth while auth is already in flight", async () => {
|
|
102
|
+
const config: McpConfig = {
|
|
103
|
+
mcpServers: {
|
|
104
|
+
github: { url: "https://api.githubcopilot.com/mcp", auth: "oauth" },
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
const callbacks = createCallbacks("needs-auth");
|
|
108
|
+
const auth = deferred<{ ok: boolean }>();
|
|
109
|
+
callbacks.authenticate = vi.fn(() => auth.promise);
|
|
110
|
+
const panel = createMcpPanel(config, createCache(config), new Map(), callbacks, { requestRender: () => {} }, () => {});
|
|
111
|
+
|
|
112
|
+
panel.handleInput("\r");
|
|
113
|
+
panel.handleInput("\r");
|
|
114
|
+
panel.handleInput("\x01");
|
|
115
|
+
|
|
116
|
+
expect(callbacks.authenticate).toHaveBeenCalledTimes(1);
|
|
117
|
+
auth.resolve({ ok: true });
|
|
118
|
+
await Promise.resolve();
|
|
119
|
+
panel.dispose();
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("filters the auth picker to OAuth-capable servers", () => {
|
|
123
|
+
const config: McpConfig = {
|
|
124
|
+
mcpServers: {
|
|
125
|
+
github: { url: "https://api.githubcopilot.com/mcp", auth: "oauth" },
|
|
126
|
+
local: { command: "node", args: ["server.js"] },
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
const callbacks = createCallbacks("needs-auth");
|
|
130
|
+
const panel = createMcpPanel(config, null, new Map(), callbacks, { requestRender: () => {} }, () => {}, {
|
|
131
|
+
authOnly: true,
|
|
132
|
+
noticeLines: ["Select an OAuth MCP server"],
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
const output = stripAnsi(panel.render(100).join("\n"));
|
|
136
|
+
expect(output).toContain("MCP OAuth");
|
|
137
|
+
expect(output).toContain("github");
|
|
138
|
+
expect(output).not.toContain("local");
|
|
139
|
+
panel.dispose();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("treats Space as a no-op in auth-only mode", () => {
|
|
143
|
+
const config: McpConfig = {
|
|
144
|
+
mcpServers: {
|
|
145
|
+
github: { url: "https://api.githubcopilot.com/mcp", auth: "oauth" },
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
const callbacks = createCallbacks("needs-auth");
|
|
149
|
+
const panel = createMcpPanel(config, null, new Map(), callbacks, { requestRender: () => {} }, () => {}, {
|
|
150
|
+
authOnly: true,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
panel.handleInput(" ");
|
|
154
|
+
|
|
155
|
+
expect(callbacks.authenticate).not.toHaveBeenCalled();
|
|
156
|
+
panel.dispose();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
it("searches server rows directly in auth-only mode", () => {
|
|
160
|
+
const config: McpConfig = {
|
|
161
|
+
mcpServers: {
|
|
162
|
+
github: { url: "https://api.githubcopilot.com/mcp", auth: "oauth" },
|
|
163
|
+
gitlab: { url: "https://gitlab.example.com/mcp", auth: "oauth" },
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
const callbacks = createCallbacks("needs-auth");
|
|
167
|
+
callbacks.canAuthenticate = () => true;
|
|
168
|
+
const panel = createMcpPanel(config, null, new Map(), callbacks, { requestRender: () => {} }, () => {}, {
|
|
169
|
+
authOnly: true,
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
panel.handleInput("l");
|
|
173
|
+
panel.handleInput("a");
|
|
174
|
+
panel.handleInput("b");
|
|
175
|
+
|
|
176
|
+
const output = stripAnsi(panel.render(100).join("\n"));
|
|
177
|
+
expect(output).toContain("gitlab");
|
|
178
|
+
expect(output).not.toContain("github");
|
|
179
|
+
panel.dispose();
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("ignores description-search shortcut in auth-only mode", () => {
|
|
183
|
+
const config: McpConfig = {
|
|
184
|
+
mcpServers: {
|
|
185
|
+
github: { url: "https://api.githubcopilot.com/mcp", auth: "oauth" },
|
|
186
|
+
gitlab: { url: "https://gitlab.example.com/mcp", auth: "oauth" },
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
const callbacks = createCallbacks("needs-auth");
|
|
190
|
+
callbacks.canAuthenticate = () => true;
|
|
191
|
+
const panel = createMcpPanel(config, null, new Map(), callbacks, { requestRender: () => {} }, () => {}, {
|
|
192
|
+
authOnly: true,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
panel.handleInput("?");
|
|
196
|
+
panel.handleInput("l");
|
|
197
|
+
panel.handleInput("a");
|
|
198
|
+
panel.handleInput("b");
|
|
199
|
+
|
|
200
|
+
const output = stripAnsi(panel.render(100).join("\n"));
|
|
201
|
+
expect(output).not.toContain("desc:");
|
|
202
|
+
expect(output).toContain("gitlab");
|
|
203
|
+
expect(output).not.toContain("github");
|
|
204
|
+
panel.dispose();
|
|
205
|
+
});
|
|
206
|
+
});
|