@evantahler/mcpx 0.15.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/.claude/settings.local.json +18 -0
- package/.claude/skills/mcpx.md +165 -0
- package/.claude/worktrees/elastic-jennings/.claude/settings.local.json +18 -0
- package/.claude/worktrees/elastic-jennings/.claude/skills/mcpcli.md +93 -0
- package/.claude/worktrees/elastic-jennings/.github/workflows/auto-release.yml +117 -0
- package/.claude/worktrees/elastic-jennings/.github/workflows/ci.yml +18 -0
- package/.claude/worktrees/elastic-jennings/.prettierignore +4 -0
- package/.claude/worktrees/elastic-jennings/.prettierrc +7 -0
- package/.claude/worktrees/elastic-jennings/CLAUDE.md +19 -0
- package/.claude/worktrees/elastic-jennings/LICENSE +21 -0
- package/.claude/worktrees/elastic-jennings/README.md +487 -0
- package/.claude/worktrees/elastic-jennings/bun.lock +381 -0
- package/.claude/worktrees/elastic-jennings/install.sh +55 -0
- package/.claude/worktrees/elastic-jennings/package.json +56 -0
- package/.claude/worktrees/elastic-jennings/src/cli.ts +39 -0
- package/.claude/worktrees/elastic-jennings/src/client/http.ts +100 -0
- package/.claude/worktrees/elastic-jennings/src/client/manager.ts +266 -0
- package/.claude/worktrees/elastic-jennings/src/client/oauth.ts +299 -0
- package/.claude/worktrees/elastic-jennings/src/client/stdio.ts +12 -0
- package/.claude/worktrees/elastic-jennings/src/commands/add.ts +155 -0
- package/.claude/worktrees/elastic-jennings/src/commands/auth.ts +114 -0
- package/.claude/worktrees/elastic-jennings/src/commands/exec.ts +91 -0
- package/.claude/worktrees/elastic-jennings/src/commands/index.ts +62 -0
- package/.claude/worktrees/elastic-jennings/src/commands/info.ts +38 -0
- package/.claude/worktrees/elastic-jennings/src/commands/list.ts +30 -0
- package/.claude/worktrees/elastic-jennings/src/commands/remove.ts +67 -0
- package/.claude/worktrees/elastic-jennings/src/commands/search.ts +45 -0
- package/.claude/worktrees/elastic-jennings/src/commands/skill.ts +70 -0
- package/.claude/worktrees/elastic-jennings/src/config/env.ts +41 -0
- package/.claude/worktrees/elastic-jennings/src/config/loader.ts +156 -0
- package/.claude/worktrees/elastic-jennings/src/config/schemas.ts +137 -0
- package/.claude/worktrees/elastic-jennings/src/context.ts +53 -0
- package/.claude/worktrees/elastic-jennings/src/output/formatter.ts +316 -0
- package/.claude/worktrees/elastic-jennings/src/output/logger.ts +114 -0
- package/.claude/worktrees/elastic-jennings/src/search/index.ts +69 -0
- package/.claude/worktrees/elastic-jennings/src/search/indexer.ts +92 -0
- package/.claude/worktrees/elastic-jennings/src/search/keyword.ts +86 -0
- package/.claude/worktrees/elastic-jennings/src/search/semantic.ts +75 -0
- package/.claude/worktrees/elastic-jennings/src/search/staleness.ts +8 -0
- package/.claude/worktrees/elastic-jennings/src/validation/schema.ts +77 -0
- package/.claude/worktrees/elastic-jennings/test/cli.test.ts +51 -0
- package/.claude/worktrees/elastic-jennings/test/client/manager.test.ts +249 -0
- package/.claude/worktrees/elastic-jennings/test/client/oauth.test.ts +328 -0
- package/.claude/worktrees/elastic-jennings/test/commands/add-remove.test.ts +253 -0
- package/.claude/worktrees/elastic-jennings/test/commands/exec.test.ts +105 -0
- package/.claude/worktrees/elastic-jennings/test/commands/info.test.ts +48 -0
- package/.claude/worktrees/elastic-jennings/test/commands/list.test.ts +39 -0
- package/.claude/worktrees/elastic-jennings/test/commands/skill.test.ts +98 -0
- package/.claude/worktrees/elastic-jennings/test/config/env.test.ts +61 -0
- package/.claude/worktrees/elastic-jennings/test/config/loader.test.ts +139 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/.keep +0 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/auth.json +10 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/mock-config/.keep +0 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/mock-config/servers.json +8 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/mock-server.ts +113 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/search.json +15 -0
- package/.claude/worktrees/elastic-jennings/test/fixtures/servers.json +18 -0
- package/.claude/worktrees/elastic-jennings/test/integration/stdio-server.test.ts +149 -0
- package/.claude/worktrees/elastic-jennings/test/output/formatter.test.ts +54 -0
- package/.claude/worktrees/elastic-jennings/test/output/logger.test.ts +89 -0
- package/.claude/worktrees/elastic-jennings/test/search/indexer.test.ts +32 -0
- package/.claude/worktrees/elastic-jennings/test/search/keyword.test.ts +80 -0
- package/.claude/worktrees/elastic-jennings/test/search/semantic.test.ts +32 -0
- package/.claude/worktrees/elastic-jennings/test/validation/schema.test.ts +113 -0
- package/.claude/worktrees/elastic-jennings/tsconfig.json +29 -0
- package/.cursor/rules/mcpx.mdc +165 -0
- package/LICENSE +21 -0
- package/README.md +627 -0
- package/package.json +58 -0
- package/src/cli.ts +72 -0
- package/src/client/browser.ts +24 -0
- package/src/client/debug-fetch.ts +81 -0
- package/src/client/elicitation.ts +368 -0
- package/src/client/http.ts +25 -0
- package/src/client/manager.ts +566 -0
- package/src/client/oauth.ts +314 -0
- package/src/client/sse.ts +17 -0
- package/src/client/stdio.ts +12 -0
- package/src/client/trace.ts +184 -0
- package/src/commands/add.ts +179 -0
- package/src/commands/auth.ts +114 -0
- package/src/commands/exec.ts +156 -0
- package/src/commands/index.ts +62 -0
- package/src/commands/info.ts +63 -0
- package/src/commands/list.ts +64 -0
- package/src/commands/ping.ts +69 -0
- package/src/commands/prompt.ts +60 -0
- package/src/commands/remove.ts +67 -0
- package/src/commands/resource.ts +46 -0
- package/src/commands/search.ts +49 -0
- package/src/commands/servers.ts +66 -0
- package/src/commands/skill.ts +112 -0
- package/src/commands/task.ts +82 -0
- package/src/config/env.ts +41 -0
- package/src/config/loader.ts +156 -0
- package/src/config/schemas.ts +152 -0
- package/src/context.ts +62 -0
- package/src/lib/input.ts +36 -0
- package/src/output/formatter.ts +884 -0
- package/src/output/logger.ts +173 -0
- package/src/search/index.ts +69 -0
- package/src/search/indexer.ts +92 -0
- package/src/search/keyword.ts +86 -0
- package/src/search/semantic.ts +75 -0
- package/src/search/staleness.ts +8 -0
- package/src/validation/schema.ts +103 -0
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { describe, test, expect, afterEach, spyOn } from "bun:test";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { ServerManager } from "../../src/client/manager.ts";
|
|
4
|
+
import { McpOAuthProvider } from "../../src/client/oauth.ts";
|
|
5
|
+
import type { ServersFile, AuthFile } from "../../src/config/schemas.ts";
|
|
6
|
+
|
|
7
|
+
const MOCK_SERVER = join(import.meta.dir, "../fixtures/mock-server.ts");
|
|
8
|
+
|
|
9
|
+
function makeServersFile(overrides?: Record<string, unknown>): ServersFile {
|
|
10
|
+
return {
|
|
11
|
+
mcpServers: {
|
|
12
|
+
mock: {
|
|
13
|
+
command: "bun",
|
|
14
|
+
args: ["run", MOCK_SERVER],
|
|
15
|
+
...overrides,
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe("ServerManager", () => {
|
|
22
|
+
let manager: ServerManager;
|
|
23
|
+
|
|
24
|
+
afterEach(async () => {
|
|
25
|
+
if (manager) await manager.close();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("connects to a stdio server and lists tools", async () => {
|
|
29
|
+
manager = new ServerManager({ servers: makeServersFile(), configDir: "/tmp", auth: {} });
|
|
30
|
+
const tools = await manager.listTools("mock");
|
|
31
|
+
const names = tools.map((t) => t.name);
|
|
32
|
+
expect(names).toContain("echo");
|
|
33
|
+
expect(names).toContain("add");
|
|
34
|
+
expect(names).toContain("secret");
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("calls a tool and gets a result", async () => {
|
|
38
|
+
manager = new ServerManager({ servers: makeServersFile(), configDir: "/tmp", auth: {} });
|
|
39
|
+
const result = (await manager.callTool("mock", "echo", { message: "hello" })) as {
|
|
40
|
+
content: { type: string; text: string }[];
|
|
41
|
+
};
|
|
42
|
+
expect(result.content[0]!.text).toBe("hello");
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("calls add tool", async () => {
|
|
46
|
+
manager = new ServerManager({ servers: makeServersFile(), configDir: "/tmp", auth: {} });
|
|
47
|
+
const result = (await manager.callTool("mock", "add", { a: 3, b: 4 })) as {
|
|
48
|
+
content: { type: string; text: string }[];
|
|
49
|
+
};
|
|
50
|
+
expect(result.content[0]!.text).toBe("7");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("applies allowedTools filter", async () => {
|
|
54
|
+
manager = new ServerManager({
|
|
55
|
+
servers: makeServersFile({ allowedTools: ["echo"] }),
|
|
56
|
+
configDir: "/tmp",
|
|
57
|
+
auth: {},
|
|
58
|
+
});
|
|
59
|
+
const tools = await manager.listTools("mock");
|
|
60
|
+
expect(tools.map((t) => t.name)).toEqual(["echo"]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("applies disabledTools filter", async () => {
|
|
64
|
+
manager = new ServerManager({
|
|
65
|
+
servers: makeServersFile({ disabledTools: ["secret"] }),
|
|
66
|
+
configDir: "/tmp",
|
|
67
|
+
auth: {},
|
|
68
|
+
});
|
|
69
|
+
const tools = await manager.listTools("mock");
|
|
70
|
+
const names = tools.map((t) => t.name);
|
|
71
|
+
expect(names).toContain("echo");
|
|
72
|
+
expect(names).toContain("add");
|
|
73
|
+
expect(names).not.toContain("secret");
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("disabledTools takes precedence over allowedTools", async () => {
|
|
77
|
+
manager = new ServerManager({
|
|
78
|
+
servers: makeServersFile({ allowedTools: ["*"], disabledTools: ["secret"] }),
|
|
79
|
+
configDir: "/tmp",
|
|
80
|
+
auth: {},
|
|
81
|
+
});
|
|
82
|
+
const tools = await manager.listTools("mock");
|
|
83
|
+
expect(tools.map((t) => t.name)).not.toContain("secret");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("getToolSchema returns a specific tool", async () => {
|
|
87
|
+
manager = new ServerManager({ servers: makeServersFile(), configDir: "/tmp", auth: {} });
|
|
88
|
+
const tool = await manager.getToolSchema("mock", "echo");
|
|
89
|
+
expect(tool).toBeDefined();
|
|
90
|
+
expect(tool!.name).toBe("echo");
|
|
91
|
+
expect(tool!.inputSchema.properties).toHaveProperty("message");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("getToolSchema returns undefined for unknown tool", async () => {
|
|
95
|
+
manager = new ServerManager({ servers: makeServersFile(), configDir: "/tmp", auth: {} });
|
|
96
|
+
const tool = await manager.getToolSchema("mock", "nonexistent");
|
|
97
|
+
expect(tool).toBeUndefined();
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("throws on unknown server", async () => {
|
|
101
|
+
manager = new ServerManager({ servers: makeServersFile(), configDir: "/tmp", auth: {} });
|
|
102
|
+
await expect(manager.listTools("nonexistent")).rejects.toThrow('Unknown server: "nonexistent"');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("getAllTools returns tools with server names", async () => {
|
|
106
|
+
manager = new ServerManager({ servers: makeServersFile(), configDir: "/tmp", auth: {} });
|
|
107
|
+
const { tools, errors } = await manager.getAllTools();
|
|
108
|
+
expect(errors).toEqual([]);
|
|
109
|
+
expect(tools.length).toBeGreaterThan(0);
|
|
110
|
+
expect(tools[0]!.server).toBe("mock");
|
|
111
|
+
expect(tools[0]!.tool.name).toBeDefined();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("caches client connections", async () => {
|
|
115
|
+
manager = new ServerManager({ servers: makeServersFile(), configDir: "/tmp", auth: {} });
|
|
116
|
+
const client1 = await manager.getClient("mock");
|
|
117
|
+
const client2 = await manager.getClient("mock");
|
|
118
|
+
expect(client1).toBe(client2);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("getServerNames returns configured servers", () => {
|
|
122
|
+
manager = new ServerManager({ servers: makeServersFile(), configDir: "/tmp", auth: {} });
|
|
123
|
+
expect(manager.getServerNames()).toEqual(["mock"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("timeout fires on slow operations", async () => {
|
|
127
|
+
manager = new ServerManager({
|
|
128
|
+
servers: makeServersFile(),
|
|
129
|
+
configDir: "/tmp",
|
|
130
|
+
auth: {},
|
|
131
|
+
timeout: 1, // 1ms — will definitely timeout
|
|
132
|
+
maxRetries: 0,
|
|
133
|
+
});
|
|
134
|
+
await expect(manager.listTools("mock")).rejects.toThrow(/timed out/);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("retries on transient failure then succeeds", async () => {
|
|
138
|
+
// Retry wrapping shouldn't break normal operations
|
|
139
|
+
manager = new ServerManager({
|
|
140
|
+
servers: makeServersFile(),
|
|
141
|
+
configDir: "/tmp",
|
|
142
|
+
auth: {},
|
|
143
|
+
maxRetries: 2,
|
|
144
|
+
});
|
|
145
|
+
const tools = await manager.listTools("mock");
|
|
146
|
+
expect(tools.length).toBeGreaterThan(0);
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("ServerManager with HTTP servers", () => {
|
|
151
|
+
let manager: ServerManager;
|
|
152
|
+
|
|
153
|
+
afterEach(async () => {
|
|
154
|
+
if (manager) await manager.close();
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("calls refreshIfNeeded for HTTP server with expired OAuth tokens", async () => {
|
|
158
|
+
const auth: AuthFile = {
|
|
159
|
+
"http-server": {
|
|
160
|
+
tokens: {
|
|
161
|
+
access_token: "expired-token",
|
|
162
|
+
token_type: "Bearer",
|
|
163
|
+
refresh_token: "my-refresh-token",
|
|
164
|
+
},
|
|
165
|
+
expires_at: new Date(Date.now() - 60000).toISOString(),
|
|
166
|
+
client_info: { client_id: "client-123" },
|
|
167
|
+
complete: true,
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
manager = new ServerManager({
|
|
172
|
+
servers: { mcpServers: { "http-server": { url: "http://localhost:19999/mcp" } } },
|
|
173
|
+
configDir: "/tmp",
|
|
174
|
+
auth,
|
|
175
|
+
timeout: 1000,
|
|
176
|
+
maxRetries: 0,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const refreshSpy = spyOn(McpOAuthProvider.prototype, "refreshIfNeeded").mockResolvedValue(
|
|
180
|
+
undefined,
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
try {
|
|
184
|
+
await manager.getClient("http-server");
|
|
185
|
+
} catch {
|
|
186
|
+
// Connection failure expected — no real HTTP server
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
|
190
|
+
expect(refreshSpy).toHaveBeenCalledWith("http://localhost:19999/mcp");
|
|
191
|
+
refreshSpy.mockRestore();
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("throws when HTTP server auth is not complete", async () => {
|
|
195
|
+
const auth: AuthFile = {
|
|
196
|
+
"http-server": {
|
|
197
|
+
tokens: { access_token: "token", token_type: "Bearer" },
|
|
198
|
+
// complete is missing
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
manager = new ServerManager({
|
|
203
|
+
servers: { mcpServers: { "http-server": { url: "http://localhost:19999/mcp" } } },
|
|
204
|
+
configDir: "/tmp",
|
|
205
|
+
auth,
|
|
206
|
+
timeout: 1000,
|
|
207
|
+
maxRetries: 0,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
await expect(manager.getClient("http-server")).rejects.toThrow("Not authenticated");
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("continues even if refreshIfNeeded throws", async () => {
|
|
214
|
+
const auth: AuthFile = {
|
|
215
|
+
"http-server": {
|
|
216
|
+
tokens: {
|
|
217
|
+
access_token: "expired-token",
|
|
218
|
+
token_type: "Bearer",
|
|
219
|
+
refresh_token: "bad-refresh-token",
|
|
220
|
+
},
|
|
221
|
+
expires_at: new Date(Date.now() - 60000).toISOString(),
|
|
222
|
+
client_info: { client_id: "client-123" },
|
|
223
|
+
complete: true,
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
manager = new ServerManager({
|
|
228
|
+
servers: { mcpServers: { "http-server": { url: "http://localhost:19999/mcp" } } },
|
|
229
|
+
configDir: "/tmp",
|
|
230
|
+
auth,
|
|
231
|
+
timeout: 1000,
|
|
232
|
+
maxRetries: 0,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
const refreshSpy = spyOn(McpOAuthProvider.prototype, "refreshIfNeeded").mockRejectedValue(
|
|
236
|
+
new Error("Refresh failed"),
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
try {
|
|
240
|
+
await manager.getClient("http-server");
|
|
241
|
+
} catch (err) {
|
|
242
|
+
// The error should be a connection error, not a refresh error
|
|
243
|
+
expect((err as Error).message).not.toContain("Refresh failed");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
expect(refreshSpy).toHaveBeenCalledTimes(1);
|
|
247
|
+
refreshSpy.mockRestore();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
import { describe, test, expect, beforeEach, afterEach, mock, spyOn } from "bun:test";
|
|
2
|
+
import { mkdtemp, rm, readFile } from "fs/promises";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { tmpdir } from "os";
|
|
5
|
+
import type { AuthFile } from "../../src/config/schemas.ts";
|
|
6
|
+
|
|
7
|
+
// Mock the SDK's refreshAuthorization before importing the provider
|
|
8
|
+
const mockRefreshAuthorization = mock(() =>
|
|
9
|
+
Promise.resolve({
|
|
10
|
+
access_token: "refreshed-access-token",
|
|
11
|
+
token_type: "Bearer",
|
|
12
|
+
expires_in: 7200,
|
|
13
|
+
refresh_token: "new-refresh-token",
|
|
14
|
+
}),
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
mock.module("@modelcontextprotocol/sdk/client/auth.js", () => ({
|
|
18
|
+
auth: mock(),
|
|
19
|
+
discoverOAuthServerInfo: mock(),
|
|
20
|
+
refreshAuthorization: mockRefreshAuthorization,
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import { McpOAuthProvider, startCallbackServer } from "../../src/client/oauth.ts";
|
|
24
|
+
import { logger } from "../../src/output/logger.ts";
|
|
25
|
+
|
|
26
|
+
function makeProvider(auth: AuthFile = {}, serverName = "test-server") {
|
|
27
|
+
const configDir = "/tmp/mcpcli-test";
|
|
28
|
+
return new McpOAuthProvider({ serverName, configDir, auth });
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("McpOAuthProvider", () => {
|
|
32
|
+
test("tokens() returns undefined for unknown server", () => {
|
|
33
|
+
const provider = makeProvider();
|
|
34
|
+
expect(provider.tokens()).toBeUndefined();
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("saveTokens() + tokens() round-trip", async () => {
|
|
38
|
+
const dir = await mkdtemp(join(tmpdir(), "mcpcli-oauth-"));
|
|
39
|
+
const auth: AuthFile = {};
|
|
40
|
+
const provider = new McpOAuthProvider({ serverName: "srv", configDir: dir, auth });
|
|
41
|
+
|
|
42
|
+
await provider.saveTokens({
|
|
43
|
+
access_token: "abc",
|
|
44
|
+
token_type: "Bearer",
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const tokens = provider.tokens();
|
|
48
|
+
expect(tokens?.access_token).toBe("abc");
|
|
49
|
+
expect(tokens?.token_type).toBe("Bearer");
|
|
50
|
+
|
|
51
|
+
await rm(dir, { recursive: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("saveTokens() computes expires_at from expires_in", async () => {
|
|
55
|
+
const dir = await mkdtemp(join(tmpdir(), "mcpcli-oauth-"));
|
|
56
|
+
const auth: AuthFile = {};
|
|
57
|
+
const provider = new McpOAuthProvider({ serverName: "srv", configDir: dir, auth });
|
|
58
|
+
|
|
59
|
+
const before = Date.now();
|
|
60
|
+
await provider.saveTokens({
|
|
61
|
+
access_token: "abc",
|
|
62
|
+
token_type: "Bearer",
|
|
63
|
+
expires_in: 3600,
|
|
64
|
+
});
|
|
65
|
+
const after = Date.now();
|
|
66
|
+
|
|
67
|
+
const expiresAt = new Date(auth["srv"]!.expires_at!).getTime();
|
|
68
|
+
expect(expiresAt).toBeGreaterThanOrEqual(before + 3600 * 1000);
|
|
69
|
+
expect(expiresAt).toBeLessThanOrEqual(after + 3600 * 1000);
|
|
70
|
+
|
|
71
|
+
await rm(dir, { recursive: true });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("clientInformation() / saveClientInformation() round-trip", async () => {
|
|
75
|
+
const dir = await mkdtemp(join(tmpdir(), "mcpcli-oauth-"));
|
|
76
|
+
const auth: AuthFile = {};
|
|
77
|
+
const provider = new McpOAuthProvider({ serverName: "srv", configDir: dir, auth });
|
|
78
|
+
|
|
79
|
+
expect(provider.clientInformation()).toBeUndefined();
|
|
80
|
+
|
|
81
|
+
await provider.saveClientInformation({
|
|
82
|
+
client_id: "my-client",
|
|
83
|
+
client_secret: "secret",
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
const info = provider.clientInformation();
|
|
87
|
+
expect(info?.client_id).toBe("my-client");
|
|
88
|
+
|
|
89
|
+
await rm(dir, { recursive: true });
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
test("codeVerifier in-memory round-trip", async () => {
|
|
93
|
+
const provider = makeProvider();
|
|
94
|
+
await provider.saveCodeVerifier("verifier-123");
|
|
95
|
+
expect(provider.codeVerifier()).toBe("verifier-123");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("codeVerifier() throws when unset", () => {
|
|
99
|
+
const provider = makeProvider();
|
|
100
|
+
expect(() => provider.codeVerifier()).toThrow("Code verifier not set");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test("isExpired() returns true for past date", () => {
|
|
104
|
+
const auth: AuthFile = {
|
|
105
|
+
"test-server": {
|
|
106
|
+
tokens: { access_token: "t", token_type: "Bearer" },
|
|
107
|
+
expires_at: new Date(Date.now() - 60000).toISOString(),
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
const provider = makeProvider(auth);
|
|
111
|
+
expect(provider.isExpired()).toBe(true);
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
test("isExpired() returns false for future date", () => {
|
|
115
|
+
const auth: AuthFile = {
|
|
116
|
+
"test-server": {
|
|
117
|
+
tokens: { access_token: "t", token_type: "Bearer" },
|
|
118
|
+
expires_at: new Date(Date.now() + 60000).toISOString(),
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
const provider = makeProvider(auth);
|
|
122
|
+
expect(provider.isExpired()).toBe(false);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("isExpired() returns false when no expires_at", () => {
|
|
126
|
+
const auth: AuthFile = {
|
|
127
|
+
"test-server": {
|
|
128
|
+
tokens: { access_token: "t", token_type: "Bearer" },
|
|
129
|
+
},
|
|
130
|
+
};
|
|
131
|
+
const provider = makeProvider(auth);
|
|
132
|
+
expect(provider.isExpired()).toBe(false);
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("invalidateCredentials clears tokens scope", async () => {
|
|
136
|
+
const dir = await mkdtemp(join(tmpdir(), "mcpcli-oauth-"));
|
|
137
|
+
const auth: AuthFile = {
|
|
138
|
+
srv: {
|
|
139
|
+
tokens: { access_token: "t", token_type: "Bearer" },
|
|
140
|
+
client_info: { client_id: "c" },
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
const provider = new McpOAuthProvider({ serverName: "srv", configDir: dir, auth });
|
|
144
|
+
|
|
145
|
+
await provider.invalidateCredentials("tokens");
|
|
146
|
+
expect(provider.tokens()?.access_token).toBeUndefined();
|
|
147
|
+
// client_info should be preserved
|
|
148
|
+
expect(provider.clientInformation()?.client_id).toBe("c");
|
|
149
|
+
|
|
150
|
+
await rm(dir, { recursive: true });
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("invalidateCredentials clears all scope", async () => {
|
|
154
|
+
const dir = await mkdtemp(join(tmpdir(), "mcpcli-oauth-"));
|
|
155
|
+
const auth: AuthFile = {
|
|
156
|
+
srv: {
|
|
157
|
+
tokens: { access_token: "t", token_type: "Bearer" },
|
|
158
|
+
client_info: { client_id: "c" },
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
const provider = new McpOAuthProvider({ serverName: "srv", configDir: dir, auth });
|
|
162
|
+
|
|
163
|
+
await provider.invalidateCredentials("all");
|
|
164
|
+
expect(provider.tokens()).toBeUndefined();
|
|
165
|
+
expect(provider.clientInformation()).toBeUndefined();
|
|
166
|
+
|
|
167
|
+
await rm(dir, { recursive: true });
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test("redirectUrl includes callback port", () => {
|
|
171
|
+
const provider = makeProvider();
|
|
172
|
+
provider.setCallbackPort(12345);
|
|
173
|
+
expect(provider.redirectUrl).toBe("http://127.0.0.1:12345/callback");
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("refreshIfNeeded", () => {
|
|
178
|
+
test("no-op when token is not expired", async () => {
|
|
179
|
+
const auth: AuthFile = {
|
|
180
|
+
"test-server": {
|
|
181
|
+
tokens: { access_token: "t", token_type: "Bearer" },
|
|
182
|
+
expires_at: new Date(Date.now() + 60000).toISOString(),
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
const provider = makeProvider(auth);
|
|
186
|
+
// Should not throw — token is still valid
|
|
187
|
+
await provider.refreshIfNeeded("http://example.com");
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("throws when expired with no refresh token", async () => {
|
|
191
|
+
const auth: AuthFile = {
|
|
192
|
+
"test-server": {
|
|
193
|
+
tokens: { access_token: "t", token_type: "Bearer" },
|
|
194
|
+
expires_at: new Date(Date.now() - 60000).toISOString(),
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
const provider = makeProvider(auth);
|
|
198
|
+
await expect(provider.refreshIfNeeded("http://example.com")).rejects.toThrow(
|
|
199
|
+
"no refresh token available",
|
|
200
|
+
);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("throws when expired with refresh token but no client info", async () => {
|
|
204
|
+
const auth: AuthFile = {
|
|
205
|
+
"test-server": {
|
|
206
|
+
tokens: {
|
|
207
|
+
access_token: "old-token",
|
|
208
|
+
token_type: "Bearer",
|
|
209
|
+
refresh_token: "my-refresh-token",
|
|
210
|
+
},
|
|
211
|
+
expires_at: new Date(Date.now() - 60000).toISOString(),
|
|
212
|
+
},
|
|
213
|
+
};
|
|
214
|
+
const provider = makeProvider(auth);
|
|
215
|
+
await expect(provider.refreshIfNeeded("http://example.com")).rejects.toThrow(
|
|
216
|
+
"No client information",
|
|
217
|
+
);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
test("refreshes token when expired with refresh token and client info", async () => {
|
|
221
|
+
const dir = await mkdtemp(join(tmpdir(), "mcpcli-oauth-refresh-"));
|
|
222
|
+
const origIsTTY = process.stderr.isTTY;
|
|
223
|
+
Object.defineProperty(process.stderr, "isTTY", { value: true, writable: true });
|
|
224
|
+
const stderrSpy = spyOn(process.stderr, "write").mockReturnValue(true);
|
|
225
|
+
logger.configure({});
|
|
226
|
+
try {
|
|
227
|
+
const auth: AuthFile = {
|
|
228
|
+
"test-server": {
|
|
229
|
+
tokens: {
|
|
230
|
+
access_token: "old-expired-token",
|
|
231
|
+
token_type: "Bearer",
|
|
232
|
+
refresh_token: "my-refresh-token",
|
|
233
|
+
},
|
|
234
|
+
expires_at: new Date(Date.now() - 60000).toISOString(),
|
|
235
|
+
client_info: { client_id: "my-client", client_secret: "my-secret" },
|
|
236
|
+
complete: true,
|
|
237
|
+
},
|
|
238
|
+
};
|
|
239
|
+
const provider = new McpOAuthProvider({
|
|
240
|
+
serverName: "test-server",
|
|
241
|
+
configDir: dir,
|
|
242
|
+
auth,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
mockRefreshAuthorization.mockClear();
|
|
246
|
+
|
|
247
|
+
await provider.refreshIfNeeded("http://example.com");
|
|
248
|
+
|
|
249
|
+
// Verify refreshAuthorization was called with correct args
|
|
250
|
+
expect(mockRefreshAuthorization).toHaveBeenCalledTimes(1);
|
|
251
|
+
expect(mockRefreshAuthorization).toHaveBeenCalledWith("http://example.com", {
|
|
252
|
+
clientInformation: { client_id: "my-client", client_secret: "my-secret" },
|
|
253
|
+
refreshToken: "my-refresh-token",
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Verify new tokens were saved in memory
|
|
257
|
+
const tokens = provider.tokens();
|
|
258
|
+
expect(tokens?.access_token).toBe("refreshed-access-token");
|
|
259
|
+
expect(tokens?.refresh_token).toBe("new-refresh-token");
|
|
260
|
+
|
|
261
|
+
// Verify expires_at was updated to a future date
|
|
262
|
+
const expiresAt = new Date(auth["test-server"]!.expires_at!).getTime();
|
|
263
|
+
expect(expiresAt).toBeGreaterThan(Date.now());
|
|
264
|
+
|
|
265
|
+
// Verify auth.json was written to disk
|
|
266
|
+
const diskContent = await readFile(join(dir, "auth.json"), "utf-8");
|
|
267
|
+
const diskAuth = JSON.parse(diskContent);
|
|
268
|
+
expect(diskAuth["test-server"].tokens.access_token).toBe("refreshed-access-token");
|
|
269
|
+
|
|
270
|
+
// Verify refresh was logged to stderr
|
|
271
|
+
expect(stderrSpy).toHaveBeenCalled();
|
|
272
|
+
const written = stderrSpy.mock.calls.map((c) => String(c[0])).join("");
|
|
273
|
+
expect(written).toContain('Token refreshed for "test-server"');
|
|
274
|
+
} finally {
|
|
275
|
+
stderrSpy.mockRestore();
|
|
276
|
+
Object.defineProperty(process.stderr, "isTTY", { value: origIsTTY, writable: true });
|
|
277
|
+
await rm(dir, { recursive: true });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe("startCallbackServer", () => {
|
|
283
|
+
let server: ReturnType<typeof Bun.serve> | undefined;
|
|
284
|
+
|
|
285
|
+
afterEach(() => {
|
|
286
|
+
if (server) {
|
|
287
|
+
server.stop();
|
|
288
|
+
server = undefined;
|
|
289
|
+
}
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test("returns authorization code on /callback?code=xxx", async () => {
|
|
293
|
+
const result = startCallbackServer();
|
|
294
|
+
server = result.server;
|
|
295
|
+
|
|
296
|
+
const url = `http://127.0.0.1:${server.port}/callback?code=test-code-123`;
|
|
297
|
+
const response = await fetch(url);
|
|
298
|
+
expect(response.status).toBe(200);
|
|
299
|
+
const html = await response.text();
|
|
300
|
+
expect(html).toContain("Authenticated");
|
|
301
|
+
|
|
302
|
+
const code = await result.authCodePromise;
|
|
303
|
+
expect(code).toBe("test-code-123");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("rejects on /callback?error=access_denied", async () => {
|
|
307
|
+
const result = startCallbackServer();
|
|
308
|
+
server = result.server;
|
|
309
|
+
|
|
310
|
+
// Catch rejection to prevent unhandled rejection
|
|
311
|
+
const errorPromise = result.authCodePromise.catch((err) => err);
|
|
312
|
+
|
|
313
|
+
const url = `http://127.0.0.1:${server.port}/callback?error=access_denied&error_description=User+denied`;
|
|
314
|
+
await fetch(url);
|
|
315
|
+
|
|
316
|
+
const err = await errorPromise;
|
|
317
|
+
expect(err).toBeInstanceOf(Error);
|
|
318
|
+
expect((err as Error).message).toContain("OAuth error: User denied");
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
test("returns 404 on unknown paths", async () => {
|
|
322
|
+
const result = startCallbackServer();
|
|
323
|
+
server = result.server;
|
|
324
|
+
|
|
325
|
+
const response = await fetch(`http://127.0.0.1:${server.port}/other`);
|
|
326
|
+
expect(response.status).toBe(404);
|
|
327
|
+
});
|
|
328
|
+
});
|