@pi-archimedes/mcp 2.3.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/LICENSE +21 -0
- package/README.md +170 -0
- package/package.json +39 -0
- package/src/auth-flow.test.ts +583 -0
- package/src/auth-flow.ts +310 -0
- package/src/auth-run.test.ts +309 -0
- package/src/auth-run.ts +146 -0
- package/src/auth-storage.test.ts +338 -0
- package/src/auth-storage.ts +330 -0
- package/src/auto-auth.test.ts +231 -0
- package/src/auto-auth.ts +135 -0
- package/src/callback-server.test.ts +446 -0
- package/src/callback-server.ts +538 -0
- package/src/commands-auth.test.ts +320 -0
- package/src/commands-auth.ts +128 -0
- package/src/commands.test.ts +834 -0
- package/src/commands.ts +424 -0
- package/src/config-write.test.ts +213 -0
- package/src/config-write.ts +207 -0
- package/src/config.test.ts +468 -0
- package/src/config.ts +278 -0
- package/src/direct-tools.test.ts +473 -0
- package/src/direct-tools.ts +250 -0
- package/src/host-configs.test.ts +231 -0
- package/src/host-configs.ts +106 -0
- package/src/index.test.ts +689 -0
- package/src/index.ts +146 -0
- package/src/lifecycle.test.ts +274 -0
- package/src/lifecycle.ts +77 -0
- package/src/metadata-cache.test.ts +383 -0
- package/src/metadata-cache.ts +231 -0
- package/src/npx-resolver.test.ts +142 -0
- package/src/npx-resolver.ts +126 -0
- package/src/oauth-provider.test.ts +404 -0
- package/src/oauth-provider.ts +197 -0
- package/src/oauth-types.ts +54 -0
- package/src/panel-rows.ts +210 -0
- package/src/panel.test.ts +298 -0
- package/src/panel.ts +742 -0
- package/src/proxy-tool.ts +524 -0
- package/src/renderer.test.ts +326 -0
- package/src/renderer.ts +239 -0
- package/src/schema-validator.test.ts +56 -0
- package/src/schema-validator.ts +42 -0
- package/src/server-client.test.ts +1001 -0
- package/src/server-client.ts +576 -0
- package/src/server-manager.ts +139 -0
- package/src/setup-panel.test.ts +162 -0
- package/src/setup-panel.ts +715 -0
- package/src/tool-naming.test.ts +168 -0
- package/src/tool-naming.ts +114 -0
- package/src/types.ts +162 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
5
|
+
import { parseNpxArgs, resolveNpxBinary } from "./npx-resolver.js";
|
|
6
|
+
|
|
7
|
+
describe("parseNpxArgs", () => {
|
|
8
|
+
it("strips -y", () => {
|
|
9
|
+
expect(parseNpxArgs(["-y", "pkg"])).toEqual(["pkg"]);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("strips --yes", () => {
|
|
13
|
+
expect(parseNpxArgs(["--yes", "pkg"])).toEqual(["pkg"]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("strips exec subcommand", () => {
|
|
17
|
+
expect(parseNpxArgs(["exec", "pkg"])).toEqual(["pkg"]);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("strips combined wrapper flags", () => {
|
|
21
|
+
expect(parseNpxArgs(["-y", "exec", "pkg"])).toEqual(["pkg"]);
|
|
22
|
+
expect(parseNpxArgs(["--yes", "-y", "pkg"])).toEqual(["pkg"]);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("keeps the package and any real args intact", () => {
|
|
26
|
+
expect(parseNpxArgs(["pkg", "--foo", "bar"])).toEqual(["pkg", "--foo", "bar"]);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it("handles pkg@version form", () => {
|
|
30
|
+
expect(parseNpxArgs(["-y", "pkg@1.2.3"])).toEqual(["pkg@1.2.3"]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("returns empty array when only wrapper flags present", () => {
|
|
34
|
+
expect(parseNpxArgs(["-y", "--yes"])).toEqual([]);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe("resolveNpxBinary", () => {
|
|
39
|
+
it("returns null for a non-npx command", async () => {
|
|
40
|
+
const result = await resolveNpxBinary("node", ["x"]);
|
|
41
|
+
expect(result).toBeNull();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("returns null for a plain executable (pure, no filesystem)", async () => {
|
|
45
|
+
const result = await resolveNpxBinary("/usr/bin/python3", ["script.py"]);
|
|
46
|
+
expect(result).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("returns a non-null resolution for an npx command", async () => {
|
|
50
|
+
// Environment-dependent: it may resolve to a real bin or fall back to the
|
|
51
|
+
// original command with wrapper flags stripped. Either way it must be non-null.
|
|
52
|
+
const result = await resolveNpxBinary("npx", ["-y", "some-real-pkg"]);
|
|
53
|
+
expect(result).not.toBeNull();
|
|
54
|
+
if (result) {
|
|
55
|
+
expect(result.command).toBeTruthy();
|
|
56
|
+
expect(Array.isArray(result.args)).toBe(true);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("returns a non-null resolution for npm exec", async () => {
|
|
61
|
+
const result = await resolveNpxBinary("npm", ["exec", "-y", "some-real-pkg"]);
|
|
62
|
+
expect(result).not.toBeNull();
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("resolveNpxBinary (filesystem fixtures)", () => {
|
|
67
|
+
let originalCwd: string;
|
|
68
|
+
const tmpDirs: string[] = [];
|
|
69
|
+
|
|
70
|
+
beforeEach(() => {
|
|
71
|
+
originalCwd = process.cwd();
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
afterEach(() => {
|
|
75
|
+
process.chdir(originalCwd);
|
|
76
|
+
for (const dir of tmpDirs.splice(0)) {
|
|
77
|
+
rmSync(dir, { recursive: true, force: true });
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
/** Create a tmp cwd with a fake package install and chdir into it. */
|
|
82
|
+
function withInstalledPkg(
|
|
83
|
+
pkgName: string,
|
|
84
|
+
pkgJson: Record<string, unknown>,
|
|
85
|
+
dotBinEntries: string[],
|
|
86
|
+
): string {
|
|
87
|
+
const dir = mkdtempSync(join(tmpdir(), "npx-resolver-"));
|
|
88
|
+
tmpDirs.push(dir);
|
|
89
|
+
const nm = join(dir, "node_modules");
|
|
90
|
+
mkdirSync(join(nm, pkgName), { recursive: true });
|
|
91
|
+
writeFileSync(
|
|
92
|
+
join(nm, pkgName, "package.json"),
|
|
93
|
+
JSON.stringify(pkgJson),
|
|
94
|
+
"utf8",
|
|
95
|
+
);
|
|
96
|
+
for (const entry of dotBinEntries) {
|
|
97
|
+
mkdirSync(join(nm, ".bin"), { recursive: true });
|
|
98
|
+
writeFileSync(join(nm, ".bin", entry), "#!/usr/bin/env node\n", "utf8");
|
|
99
|
+
}
|
|
100
|
+
process.chdir(dir);
|
|
101
|
+
return dir;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
it("resolves object-form bin using the bin key, not the value", async () => {
|
|
105
|
+
const dir = withInstalledPkg(
|
|
106
|
+
"fixture-key-pkg",
|
|
107
|
+
{ name: "fixture-key-pkg", bin: { foo: "cli.js" } },
|
|
108
|
+
["foo"],
|
|
109
|
+
);
|
|
110
|
+
const result = await resolveNpxBinary("npx", ["-y", "fixture-key-pkg", "--flag"]);
|
|
111
|
+
expect(result).toEqual({
|
|
112
|
+
command: join(dir, "node_modules", ".bin", "foo"),
|
|
113
|
+
args: ["--flag"],
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("resolves string-form bin using the package name", async () => {
|
|
118
|
+
const dir = withInstalledPkg(
|
|
119
|
+
"fixture-string-pkg",
|
|
120
|
+
{ name: "fixture-string-pkg", bin: "cli.js" },
|
|
121
|
+
["fixture-string-pkg"],
|
|
122
|
+
);
|
|
123
|
+
const result = await resolveNpxBinary("npx", ["fixture-string-pkg"]);
|
|
124
|
+
expect(result).toEqual({
|
|
125
|
+
command: join(dir, "node_modules", ".bin", "fixture-string-pkg"),
|
|
126
|
+
args: [],
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("fallback (no package found) returns the original command and args unchanged", async () => {
|
|
131
|
+
withInstalledPkg("fixture-unrelated-pkg", { name: "fixture-unrelated-pkg" }, []);
|
|
132
|
+
const result = await resolveNpxBinary("npx", [
|
|
133
|
+
"-y",
|
|
134
|
+
"definitely-not-installed-xyz-42",
|
|
135
|
+
"--port", "8080",
|
|
136
|
+
]);
|
|
137
|
+
expect(result).toEqual({
|
|
138
|
+
command: "npx",
|
|
139
|
+
args: ["-y", "definitely-not-installed-xyz-42", "--port", "8080"],
|
|
140
|
+
});
|
|
141
|
+
});
|
|
142
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export interface NpxResolution {
|
|
6
|
+
command: string;
|
|
7
|
+
args: string[];
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Flags/subcommands that wrap the real package argument. */
|
|
11
|
+
const WRAPPER_FLAGS = new Set(["-y", "--yes", "exec"]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Pure helper: strip npx wrapper flags (-y, --yes, exec) from args.
|
|
15
|
+
* The first remaining argument is the package (or bin) name.
|
|
16
|
+
*/
|
|
17
|
+
export function parseNpxArgs(args: string[]): string[] {
|
|
18
|
+
return args.filter((a) => !WRAPPER_FLAGS.has(a));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* True when the command is `npx`, or `npm` invoked with the `exec`/`npx`
|
|
23
|
+
* subcommand. The command may be an absolute path; only its basename matters.
|
|
24
|
+
*/
|
|
25
|
+
function isNpxCommand(command: string, args: string[]): boolean {
|
|
26
|
+
const base = basename(command);
|
|
27
|
+
if (base === "npx") return true;
|
|
28
|
+
if (base === "npm") return args[0] === "exec" || args[0] === "npx";
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Strip a trailing `@version` from a package name (handles scoped names). */
|
|
33
|
+
function stripVersion(arg: string): string {
|
|
34
|
+
const start = arg.startsWith("@") ? 1 : 0;
|
|
35
|
+
const at = arg.indexOf("@", start);
|
|
36
|
+
return at === -1 ? arg : arg.slice(0, at);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read the bin name(s) declared by a package's package.json (bin *keys*). */
|
|
40
|
+
function readPackageBinNames(pkgDir: string): string[] {
|
|
41
|
+
try {
|
|
42
|
+
const raw = readFileSync(join(pkgDir, "package.json"), "utf8");
|
|
43
|
+
const pkg = JSON.parse(raw) as { bin?: unknown; name?: unknown };
|
|
44
|
+
// String form: npm names the .bin symlink after the package name.
|
|
45
|
+
if (typeof pkg.bin === "string") {
|
|
46
|
+
return typeof pkg.name === "string" && pkg.name ? [pkg.name] : [];
|
|
47
|
+
}
|
|
48
|
+
if (pkg.bin && typeof pkg.bin === "object") {
|
|
49
|
+
return Object.keys(pkg.bin as Record<string, string>);
|
|
50
|
+
}
|
|
51
|
+
} catch {
|
|
52
|
+
// Missing/unreadable package.json — no declared bin.
|
|
53
|
+
}
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Directories that contain a `node_modules` tree worth checking. */
|
|
58
|
+
function nodeModulesBases(cwd: string): string[] {
|
|
59
|
+
const bases: string[] = [cwd];
|
|
60
|
+
|
|
61
|
+
// npx/npm install cache (~/.npm/_npx/<hash>)
|
|
62
|
+
const npxCache = join(homedir(), ".npm", "_npx");
|
|
63
|
+
try {
|
|
64
|
+
for (const entry of readdirSync(npxCache)) {
|
|
65
|
+
bases.push(join(npxCache, entry));
|
|
66
|
+
}
|
|
67
|
+
} catch {
|
|
68
|
+
// No cache present — ignore.
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Global installs relative to the running node binary (<prefix>/lib).
|
|
72
|
+
bases.push(join(dirname(process.execPath), "..", "lib"));
|
|
73
|
+
|
|
74
|
+
return bases;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Locate the actual bin of `pkg` by finding its package.json and resolving the
|
|
79
|
+
* declared bin name against the sibling `node_modules/.bin`. Returns the absolute
|
|
80
|
+
* bin path, or null when it cannot be found.
|
|
81
|
+
*/
|
|
82
|
+
function findBinPath(cwd: string, pkg: string): string | null {
|
|
83
|
+
for (const base of nodeModulesBases(cwd)) {
|
|
84
|
+
const binNames = readPackageBinNames(join(base, "node_modules", pkg));
|
|
85
|
+
for (const binName of binNames) {
|
|
86
|
+
const binPath = join(base, "node_modules", ".bin", binName);
|
|
87
|
+
try {
|
|
88
|
+
if (existsSync(binPath) && statSync(binPath).isFile()) return binPath;
|
|
89
|
+
} catch {
|
|
90
|
+
// Unreadable entry — skip.
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* If command is `npx`/`npm exec`, attempt to resolve the actual package binary
|
|
99
|
+
* so we spawn it directly instead of the npm parent.
|
|
100
|
+
*
|
|
101
|
+
* Returns null when the command is NOT an npx/npm command (caller uses the
|
|
102
|
+
* original command/args). Otherwise always returns a resolution: either the
|
|
103
|
+
* resolved bin path (with wrapper flags stripped), or the original command and
|
|
104
|
+
* args *unchanged* when no bin could be located (graceful degradation — never
|
|
105
|
+
* null for npx/npm, never strips flags we didn't consume).
|
|
106
|
+
*/
|
|
107
|
+
export async function resolveNpxBinary(
|
|
108
|
+
command: string,
|
|
109
|
+
args: string[],
|
|
110
|
+
): Promise<NpxResolution | null> {
|
|
111
|
+
if (!isNpxCommand(command, args)) return null;
|
|
112
|
+
|
|
113
|
+
const parsed = parseNpxArgs(args);
|
|
114
|
+
const first = parsed[0];
|
|
115
|
+
const rest = parsed.slice(1);
|
|
116
|
+
|
|
117
|
+
// No package argument (e.g. bare `npx`) — degrade to the original.
|
|
118
|
+
if (!first) return { command, args };
|
|
119
|
+
|
|
120
|
+
const bin = findBinPath(process.cwd(), stripVersion(first));
|
|
121
|
+
if (bin) return { command: bin, args: rest };
|
|
122
|
+
|
|
123
|
+
// No bin found — degrade gracefully to the original command/args unchanged
|
|
124
|
+
// (keep -y/--yes so npx doesn't prompt; never return null for npx/npm).
|
|
125
|
+
return { command, args };
|
|
126
|
+
}
|
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
|
+
|
|
3
|
+
import type { AuthEntry } from "./oauth-types.js";
|
|
4
|
+
import type { McpOAuthConfig } from "./types.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Mocks for the storage and callback-server boundaries so the provider is
|
|
8
|
+
* exercised in isolation:
|
|
9
|
+
* - `auth-storage.js` — in-memory AuthEntry per server name. The mock
|
|
10
|
+
* mirrors the real module's contract: `getAuthEntry` returns a clone,
|
|
11
|
+
* `saveAuthEntry` replaces the stored entry and mutates `entry.serverUrl`
|
|
12
|
+
* when the param is passed.
|
|
13
|
+
* - `callback-server.js` — pinned constants so redirect URL assertions are
|
|
14
|
+
* independent of the MCP_OAUTH_CALLBACK_PORT env var.
|
|
15
|
+
*/
|
|
16
|
+
const storage = vi.hoisted(() => {
|
|
17
|
+
const entries = new Map<string, AuthEntry>();
|
|
18
|
+
const clone = <T>(value: T): T => (value === undefined ? value : structuredClone(value));
|
|
19
|
+
|
|
20
|
+
const getAuthEntry = vi.fn((serverName: string): AuthEntry | undefined =>
|
|
21
|
+
clone(entries.get(serverName)),
|
|
22
|
+
);
|
|
23
|
+
const saveAuthEntry = vi.fn(
|
|
24
|
+
(serverName: string, entry: AuthEntry, serverUrl?: string): void => {
|
|
25
|
+
if (serverUrl !== undefined) entry.serverUrl = serverUrl;
|
|
26
|
+
entries.set(serverName, clone(entry));
|
|
27
|
+
},
|
|
28
|
+
);
|
|
29
|
+
const deleteAuthEntry = vi.fn((serverName: string): void => {
|
|
30
|
+
entries.delete(serverName);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
entries,
|
|
35
|
+
getAuthEntry,
|
|
36
|
+
saveAuthEntry,
|
|
37
|
+
deleteAuthEntry,
|
|
38
|
+
seed(serverName: string, entry: AuthEntry): void {
|
|
39
|
+
entries.set(serverName, clone(entry));
|
|
40
|
+
},
|
|
41
|
+
reset(): void {
|
|
42
|
+
entries.clear();
|
|
43
|
+
getAuthEntry.mockClear();
|
|
44
|
+
saveAuthEntry.mockClear();
|
|
45
|
+
deleteAuthEntry.mockClear();
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
vi.mock("./auth-storage.js", () => ({
|
|
51
|
+
getAuthEntry: storage.getAuthEntry,
|
|
52
|
+
saveAuthEntry: storage.saveAuthEntry,
|
|
53
|
+
deleteAuthEntry: storage.deleteAuthEntry,
|
|
54
|
+
}));
|
|
55
|
+
|
|
56
|
+
vi.mock("./callback-server.js", () => ({
|
|
57
|
+
getCallbackPort: () => 19876,
|
|
58
|
+
getCallbackPath: () => "/callback",
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
import { McpOAuthProvider, type OAuthCallbacks } from "./oauth-provider.js";
|
|
62
|
+
|
|
63
|
+
const SERVER_NAME = "test-server";
|
|
64
|
+
const SERVER_URL = "https://mcp.example.com";
|
|
65
|
+
|
|
66
|
+
/** Fixed wall clock for deterministic expiresAt/expires_in math. */
|
|
67
|
+
const FAKE_NOW_MS = Date.parse("2026-02-03T12:00:00.000Z");
|
|
68
|
+
const FAKE_NOW_SECONDS = FAKE_NOW_MS / 1000;
|
|
69
|
+
|
|
70
|
+
const DEFAULT_REDIRECT = "http://localhost:19876/callback";
|
|
71
|
+
|
|
72
|
+
function makeProvider(
|
|
73
|
+
config: McpOAuthConfig = {},
|
|
74
|
+
callbacks: OAuthCallbacks = {},
|
|
75
|
+
csrfState?: string,
|
|
76
|
+
callbackPort?: number,
|
|
77
|
+
): McpOAuthProvider {
|
|
78
|
+
return new McpOAuthProvider(SERVER_NAME, SERVER_URL, config, callbacks, csrfState, callbackPort);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
beforeEach(() => {
|
|
82
|
+
storage.reset();
|
|
83
|
+
vi.useFakeTimers({ now: FAKE_NOW_MS });
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
afterEach(() => {
|
|
87
|
+
vi.useRealTimers();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
describe("McpOAuthProvider", () => {
|
|
91
|
+
describe("clientMetadata", () => {
|
|
92
|
+
it("authorization_code: localhost redirect, grants, response types, no scope", () => {
|
|
93
|
+
const metadata = makeProvider({ grantType: "authorization_code" }).clientMetadata;
|
|
94
|
+
expect(metadata.redirect_uris).toEqual([DEFAULT_REDIRECT]);
|
|
95
|
+
expect(metadata.grant_types).toEqual(["authorization_code", "refresh_token"]);
|
|
96
|
+
expect(metadata.response_types).toEqual(["code"]);
|
|
97
|
+
expect(metadata.client_name).toBe(SERVER_NAME);
|
|
98
|
+
expect(Object.keys(metadata)).not.toContain("scope");
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("authorization_code: scope present only when configured", () => {
|
|
102
|
+
const withScope = makeProvider({ grantType: "authorization_code", scope: "mcp tools" });
|
|
103
|
+
expect(withScope.clientMetadata.scope).toBe("mcp tools");
|
|
104
|
+
|
|
105
|
+
const withoutScope = makeProvider({ grantType: "authorization_code" });
|
|
106
|
+
expect(Object.keys(withoutScope.clientMetadata)).not.toContain("scope");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("authorization_code: client_secret_post only with a client secret", () => {
|
|
110
|
+
expect(makeProvider({ grantType: "authorization_code" }).clientMetadata.token_endpoint_auth_method).toBe(
|
|
111
|
+
"none",
|
|
112
|
+
);
|
|
113
|
+
expect(
|
|
114
|
+
makeProvider({ grantType: "authorization_code", clientSecret: "shh" })
|
|
115
|
+
.clientMetadata
|
|
116
|
+
.token_endpoint_auth_method,
|
|
117
|
+
).toBe("client_secret_post");
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("authorization_code: config.redirectUri wins over the default", () => {
|
|
121
|
+
const metadata = makeProvider({
|
|
122
|
+
grantType: "authorization_code",
|
|
123
|
+
redirectUri: "https://app.example.com/oauth/callback",
|
|
124
|
+
}).clientMetadata;
|
|
125
|
+
expect(metadata.redirect_uris).toEqual(["https://app.example.com/oauth/callback"]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("authorization_code: redirect_uris advertise the actual bound callback port", () => {
|
|
129
|
+
const metadata = makeProvider({ grantType: "authorization_code" }, {}, undefined, 43217)
|
|
130
|
+
.clientMetadata;
|
|
131
|
+
expect(metadata.redirect_uris).toEqual(["http://localhost:43217/callback"]);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("authorization_code: client_name honors config, defaults to server name", () => {
|
|
135
|
+
expect(makeProvider({ grantType: "authorization_code" }).clientMetadata.client_name).toBe(
|
|
136
|
+
SERVER_NAME,
|
|
137
|
+
);
|
|
138
|
+
expect(
|
|
139
|
+
makeProvider({ grantType: "authorization_code", clientName: "My App" }).clientMetadata
|
|
140
|
+
.client_name,
|
|
141
|
+
).toBe("My App");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("client_credentials: empty redirect_uris and correct grants", () => {
|
|
145
|
+
const metadata = makeProvider({ grantType: "client_credentials" }).clientMetadata;
|
|
146
|
+
expect(metadata.redirect_uris).toEqual([]);
|
|
147
|
+
expect(metadata.grant_types).toEqual(["client_credentials"]);
|
|
148
|
+
expect(metadata.token_endpoint_auth_method).toBe("none");
|
|
149
|
+
expect(metadata.client_name).toBe(SERVER_NAME);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it("client_credentials: client_secret_post with a secret", () => {
|
|
153
|
+
expect(
|
|
154
|
+
makeProvider({ grantType: "client_credentials", clientSecret: "shh" }).clientMetadata
|
|
155
|
+
.token_endpoint_auth_method,
|
|
156
|
+
).toBe("client_secret_post");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("redirectUrl", () => {
|
|
161
|
+
it("is undefined for client_credentials", () => {
|
|
162
|
+
expect(makeProvider({ grantType: "client_credentials" }).redirectUrl).toBeUndefined();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it("defaults to localhost for authorization_code (explicit and default grant type)", () => {
|
|
166
|
+
expect(makeProvider({ grantType: "authorization_code" }).redirectUrl).toBe(DEFAULT_REDIRECT);
|
|
167
|
+
expect(makeProvider({}).redirectUrl).toBe(DEFAULT_REDIRECT);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("config.redirectUri wins when set", () => {
|
|
171
|
+
expect(
|
|
172
|
+
makeProvider({ grantType: "authorization_code", redirectUri: "https://app.example.com/cb" })
|
|
173
|
+
.redirectUrl,
|
|
174
|
+
).toBe("https://app.example.com/cb");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("uses the actual bound callback port (not the default) when provided", () => {
|
|
178
|
+
expect(makeProvider({ grantType: "authorization_code" }, {}, undefined, 43217).redirectUrl).toBe(
|
|
179
|
+
"http://localhost:43217/callback",
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("config.redirectUri wins over callbackPort", () => {
|
|
184
|
+
expect(
|
|
185
|
+
makeProvider(
|
|
186
|
+
{ grantType: "authorization_code", redirectUri: "https://app.example.com/cb" },
|
|
187
|
+
{},
|
|
188
|
+
undefined,
|
|
189
|
+
43217,
|
|
190
|
+
).redirectUrl,
|
|
191
|
+
).toBe("https://app.example.com/cb");
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
describe("tokens round-trip", () => {
|
|
196
|
+
it("saveTokens maps expires_in to expiresAt and preserves seeded clientInfo", async () => {
|
|
197
|
+
storage.seed(SERVER_NAME, {
|
|
198
|
+
clientInfo: { clientId: "seed-client", clientSecret: "seed-secret" },
|
|
199
|
+
});
|
|
200
|
+
const provider = makeProvider({ grantType: "authorization_code" });
|
|
201
|
+
|
|
202
|
+
await provider.saveTokens({
|
|
203
|
+
access_token: "at-1",
|
|
204
|
+
token_type: "Bearer",
|
|
205
|
+
expires_in: 3600,
|
|
206
|
+
refresh_token: "rt-1",
|
|
207
|
+
scope: "mcp",
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
const saved = storage.entries.get(SERVER_NAME);
|
|
211
|
+
expect(saved?.tokens).toEqual({
|
|
212
|
+
accessToken: "at-1",
|
|
213
|
+
refreshToken: "rt-1",
|
|
214
|
+
expiresAt: FAKE_NOW_SECONDS + 3600,
|
|
215
|
+
scope: "mcp",
|
|
216
|
+
});
|
|
217
|
+
expect(saved?.clientInfo).toEqual({ clientId: "seed-client", clientSecret: "seed-secret" });
|
|
218
|
+
expect(storage.saveAuthEntry).toHaveBeenCalledWith(
|
|
219
|
+
SERVER_NAME,
|
|
220
|
+
expect.objectContaining({
|
|
221
|
+
clientInfo: { clientId: "seed-client", clientSecret: "seed-secret" },
|
|
222
|
+
}),
|
|
223
|
+
SERVER_URL,
|
|
224
|
+
);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("saveTokens omits optional fields that were not provided", async () => {
|
|
228
|
+
const provider = makeProvider({ grantType: "authorization_code" });
|
|
229
|
+
await provider.saveTokens({ access_token: "at-2", token_type: "Bearer" });
|
|
230
|
+
|
|
231
|
+
const saved = storage.entries.get(SERVER_NAME);
|
|
232
|
+
expect(Object.keys(saved?.tokens ?? {})).toEqual(["accessToken"]);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("tokens maps back with token_type Bearer and remaining expires_in", async () => {
|
|
236
|
+
storage.seed(SERVER_NAME, {
|
|
237
|
+
tokens: {
|
|
238
|
+
accessToken: "at-9",
|
|
239
|
+
refreshToken: "rt-9",
|
|
240
|
+
expiresAt: FAKE_NOW_SECONDS + 3600,
|
|
241
|
+
scope: "mcp",
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
const provider = makeProvider({ grantType: "authorization_code" });
|
|
245
|
+
|
|
246
|
+
expect(await provider.tokens()).toEqual({
|
|
247
|
+
access_token: "at-9",
|
|
248
|
+
token_type: "Bearer",
|
|
249
|
+
refresh_token: "rt-9",
|
|
250
|
+
expires_in: 3600,
|
|
251
|
+
scope: "mcp",
|
|
252
|
+
});
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
it("tokens returns undefined when nothing is stored", async () => {
|
|
256
|
+
expect(await makeProvider({}).tokens()).toBeUndefined();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("tokens returns expired tokens with expires_in 0 (SDK drives the refresh)", async () => {
|
|
260
|
+
storage.seed(SERVER_NAME, {
|
|
261
|
+
tokens: { accessToken: "at-exp", expiresAt: FAKE_NOW_SECONDS - 500 },
|
|
262
|
+
});
|
|
263
|
+
expect(await makeProvider({}).tokens()).toEqual({
|
|
264
|
+
access_token: "at-exp",
|
|
265
|
+
token_type: "Bearer",
|
|
266
|
+
expires_in: 0,
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
describe("clientInformation", () => {
|
|
272
|
+
it("uses config.clientId without reading storage", async () => {
|
|
273
|
+
const provider = makeProvider({ grantType: "authorization_code", clientId: "cfg-client" });
|
|
274
|
+
const readsBefore = storage.getAuthEntry.mock.calls.length;
|
|
275
|
+
|
|
276
|
+
expect(await provider.clientInformation()).toEqual({ client_id: "cfg-client" });
|
|
277
|
+
expect(storage.getAuthEntry.mock.calls.length).toBe(readsBefore);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("includes config.clientSecret when set", async () => {
|
|
281
|
+
const provider = makeProvider({
|
|
282
|
+
grantType: "authorization_code",
|
|
283
|
+
clientId: "cfg-client",
|
|
284
|
+
clientSecret: "cfg-secret",
|
|
285
|
+
});
|
|
286
|
+
expect(await provider.clientInformation()).toEqual({
|
|
287
|
+
client_id: "cfg-client",
|
|
288
|
+
client_secret: "cfg-secret",
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("falls back to stored clientInfo when config has no clientId", async () => {
|
|
293
|
+
storage.seed(SERVER_NAME, {
|
|
294
|
+
clientInfo: { clientId: "stored-client", redirectUris: ["https://app.example.com/cb"] },
|
|
295
|
+
});
|
|
296
|
+
const provider = makeProvider({ grantType: "authorization_code" });
|
|
297
|
+
|
|
298
|
+
expect(await provider.clientInformation()).toEqual({
|
|
299
|
+
client_id: "stored-client",
|
|
300
|
+
redirect_uris: ["https://app.example.com/cb"],
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it("returns undefined when there is no stored client info", async () => {
|
|
305
|
+
expect(await makeProvider({ grantType: "authorization_code" }).clientInformation()).toBeUndefined();
|
|
306
|
+
});
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
describe("saveClientInformation", () => {
|
|
310
|
+
it("persists registered client info and preserves existing tokens", async () => {
|
|
311
|
+
storage.seed(SERVER_NAME, { tokens: { accessToken: "keep-me" } });
|
|
312
|
+
const provider = makeProvider({ grantType: "authorization_code" });
|
|
313
|
+
|
|
314
|
+
await provider.saveClientInformation({
|
|
315
|
+
client_id: "dyn-client",
|
|
316
|
+
redirect_uris: [DEFAULT_REDIRECT],
|
|
317
|
+
client_name: "pi-archimedes mcp client",
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const saved = storage.entries.get(SERVER_NAME);
|
|
321
|
+
expect(saved?.clientInfo).toEqual({
|
|
322
|
+
clientId: "dyn-client",
|
|
323
|
+
redirectUris: [DEFAULT_REDIRECT],
|
|
324
|
+
});
|
|
325
|
+
expect(saved?.tokens).toEqual({ accessToken: "keep-me" });
|
|
326
|
+
expect(storage.saveAuthEntry).toHaveBeenCalledWith(
|
|
327
|
+
SERVER_NAME,
|
|
328
|
+
expect.objectContaining({
|
|
329
|
+
clientInfo: { clientId: "dyn-client", redirectUris: [DEFAULT_REDIRECT] },
|
|
330
|
+
}),
|
|
331
|
+
SERVER_URL,
|
|
332
|
+
);
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("stores the client secret when the registration response includes one", async () => {
|
|
336
|
+
const provider = makeProvider({ grantType: "authorization_code" });
|
|
337
|
+
|
|
338
|
+
await provider.saveClientInformation({
|
|
339
|
+
client_id: "dyn-client",
|
|
340
|
+
client_secret: "dyn-secret",
|
|
341
|
+
redirect_uris: [DEFAULT_REDIRECT],
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
expect(storage.entries.get(SERVER_NAME)?.clientInfo).toEqual({
|
|
345
|
+
clientId: "dyn-client",
|
|
346
|
+
clientSecret: "dyn-secret",
|
|
347
|
+
redirectUris: [DEFAULT_REDIRECT],
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
describe("codeVerifier", () => {
|
|
353
|
+
it("round-trips through storage", async () => {
|
|
354
|
+
const provider = makeProvider({ grantType: "authorization_code" });
|
|
355
|
+
await provider.saveCodeVerifier("verifier-123");
|
|
356
|
+
|
|
357
|
+
expect(storage.entries.get(SERVER_NAME)?.codeVerifier).toBe("verifier-123");
|
|
358
|
+
expect(await provider.codeVerifier()).toBe("verifier-123");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("preserves tokens when saving the verifier", async () => {
|
|
362
|
+
storage.seed(SERVER_NAME, { tokens: { accessToken: "keep-me" } });
|
|
363
|
+
const provider = makeProvider({ grantType: "authorization_code" });
|
|
364
|
+
|
|
365
|
+
await provider.saveCodeVerifier("verifier-xyz");
|
|
366
|
+
expect(storage.entries.get(SERVER_NAME)?.tokens).toEqual({ accessToken: "keep-me" });
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
it("throws when no verifier has been saved", async () => {
|
|
370
|
+
const provider = makeProvider({ grantType: "authorization_code" });
|
|
371
|
+
await expect(provider.codeVerifier()).rejects.toThrow("Missing OAuth code verifier");
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
describe("state", () => {
|
|
376
|
+
it("returns the CSRF state when constructed with one", () => {
|
|
377
|
+
expect(makeProvider({}, {}, "csrf-42").state()).toBe("csrf-42");
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it("returns an empty string without state (intentional for client_credentials)", () => {
|
|
381
|
+
expect(makeProvider({ grantType: "client_credentials" }).state()).toBe("");
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
describe("redirectToAuthorization", () => {
|
|
386
|
+
it("forwards the URL to onAuthorizationUrl", async () => {
|
|
387
|
+
const onAuthorizationUrl = vi.fn();
|
|
388
|
+
const provider = makeProvider({}, { onAuthorizationUrl });
|
|
389
|
+
const url = new URL("https://auth.example.com/authorize?state=abc");
|
|
390
|
+
|
|
391
|
+
await provider.redirectToAuthorization(url);
|
|
392
|
+
|
|
393
|
+
expect(onAuthorizationUrl).toHaveBeenCalledTimes(1);
|
|
394
|
+
expect(onAuthorizationUrl).toHaveBeenCalledWith(url);
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
it("resolves without callbacks", async () => {
|
|
398
|
+
const provider = makeProvider({});
|
|
399
|
+
await expect(
|
|
400
|
+
provider.redirectToAuthorization(new URL("https://auth.example.com/authorize")),
|
|
401
|
+
).resolves.toBeUndefined();
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
});
|