@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,207 @@
|
|
|
1
|
+
import { CONFIG_DIR_NAME } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
import { stripJsonComments } from "./config.js";
|
|
5
|
+
import type { ServerDef } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Safe write-back helpers for the project-local Pi override file
|
|
9
|
+
* `<cwd>/<CONFIG_DIR_NAME>/mcp.json` (ADR 0002: the /mcp command and the
|
|
10
|
+
* management panel only ever write the single changed field here).
|
|
11
|
+
*
|
|
12
|
+
* Security rules:
|
|
13
|
+
* - Only ONE field is ever added (`disabled` or `directTools`) — under
|
|
14
|
+
* `mcpServers[serverName]`. No other field is ever read into these
|
|
15
|
+
* helpers and written out, so credentials are never copied.
|
|
16
|
+
* - The read is tolerant (`//` comments via `stripJsonComments`, trailing
|
|
17
|
+
* comma, missing/empty file → `{ mcpServers: {} }`), but a file that
|
|
18
|
+
* doesn't parse is REFUSED (throw) rather than silently overwritten —
|
|
19
|
+
* clobbering an unknown/corrupt file could destroy credentials.
|
|
20
|
+
* - The write is atomic: tmp file in the same dir, then rename over the
|
|
21
|
+
* target (same pattern as `@pi-archimedes/core` settings-io).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
/** Parsed override document; extra top-level keys are preserved as-is. */
|
|
25
|
+
type McpOverrideDoc = Record<string, unknown> & { mcpServers: Record<string, Record<string, unknown>> };
|
|
26
|
+
|
|
27
|
+
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
|
28
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Atomic write of a JSON document: tmp file in the SAME dir as the target,
|
|
33
|
+
* then rename over the target. 2-space indent + trailing newline. The
|
|
34
|
+
* parent dir is created (recursive) when missing. Named path (not cwd+
|
|
35
|
+
* config file) so both the override writer above, the project writer below,
|
|
36
|
+
* and outside callers (setup-panel scaffold) share this exact pattern.
|
|
37
|
+
* On a failed rename the tmp file is removed before re-throwing.
|
|
38
|
+
*/
|
|
39
|
+
export function writeJsonFileAtomic(path: string, doc: unknown): void {
|
|
40
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
41
|
+
const tmp = path + ".tmp";
|
|
42
|
+
const json = JSON.stringify(doc, null, 2) + "\n";
|
|
43
|
+
writeFileSync(tmp, json, "utf-8");
|
|
44
|
+
try {
|
|
45
|
+
renameSync(tmp, path);
|
|
46
|
+
} catch (e) {
|
|
47
|
+
try {
|
|
48
|
+
unlinkSync(tmp);
|
|
49
|
+
} catch {
|
|
50
|
+
/* ignore */
|
|
51
|
+
}
|
|
52
|
+
throw e;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function configPath(cwd: string): string {
|
|
57
|
+
return join(cwd, CONFIG_DIR_NAME, "mcp.json");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Read the project-local override file into a doc object.
|
|
62
|
+
* Missing or empty file → `{ mcpServers: {} }`. Unparseable or
|
|
63
|
+
* wrongly-shaped file → throw (never clobber data we cannot understand).
|
|
64
|
+
*/
|
|
65
|
+
function readDoc(cwd: string): McpOverrideDoc {
|
|
66
|
+
const path = configPath(cwd);
|
|
67
|
+
if (!existsSync(path)) return { mcpServers: {} };
|
|
68
|
+
const raw = readFileSync(path, "utf-8");
|
|
69
|
+
if (raw.trim() === "") return { mcpServers: {} };
|
|
70
|
+
|
|
71
|
+
let parsed: unknown;
|
|
72
|
+
try {
|
|
73
|
+
parsed = JSON.parse(stripJsonComments(raw));
|
|
74
|
+
} catch (e) {
|
|
75
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
76
|
+
throw new Error(`[archimedes/mcp] Refusing to overwrite ${path} — unparseable JSON: ${msg}`);
|
|
77
|
+
}
|
|
78
|
+
if (!isPlainObject(parsed)) {
|
|
79
|
+
throw new Error(`[archimedes/mcp] Refusing to overwrite ${path} — top level is not an object`);
|
|
80
|
+
}
|
|
81
|
+
const mcpServers = parsed.mcpServers;
|
|
82
|
+
if (mcpServers !== undefined && !isPlainObject(mcpServers)) {
|
|
83
|
+
throw new Error(`[archimedes/mcp] Refusing to overwrite ${path} — "mcpServers" is not an object`);
|
|
84
|
+
}
|
|
85
|
+
const servers: Record<string, Record<string, unknown>> = {};
|
|
86
|
+
if (isPlainObject(mcpServers)) {
|
|
87
|
+
for (const [name, def] of Object.entries(mcpServers)) {
|
|
88
|
+
if (!isPlainObject(def)) {
|
|
89
|
+
throw new Error(`[archimedes/mcp] Refusing to overwrite ${path} — "mcpServers.${name}" is not an object`);
|
|
90
|
+
}
|
|
91
|
+
servers[name] = def;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return { ...parsed, mcpServers: servers };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Atomic write of the Pi override file (shared writer, `configPath` target). */
|
|
98
|
+
function writeDoc(cwd: string, doc: McpOverrideDoc): void {
|
|
99
|
+
writeJsonFileAtomic(configPath(cwd), doc);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Set one field on one server entry, read-modify-write. Existing fields of
|
|
104
|
+
* that server, all other servers, and any extra top-level keys are preserved
|
|
105
|
+
* untouched; the only bytes added are the single field being written.
|
|
106
|
+
*/
|
|
107
|
+
function writeServerField(cwd: string, serverName: string, field: "disabled" | "directTools", value: unknown): void {
|
|
108
|
+
const doc = readDoc(cwd);
|
|
109
|
+
const server = doc.mcpServers[serverName] ?? {};
|
|
110
|
+
server[field] = value;
|
|
111
|
+
doc.mcpServers[serverName] = server;
|
|
112
|
+
writeDoc(cwd, doc);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Write only `{ disabled }` for a server into `<cwd>/<CONFIG_DIR_NAME>/mcp.json`
|
|
117
|
+
* (creating the dir and file if missing). Never copies credentials.
|
|
118
|
+
*/
|
|
119
|
+
export function writeServerDisabled(cwd: string, serverName: string, disabled: boolean): void {
|
|
120
|
+
writeServerField(cwd, serverName, "disabled", disabled);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Write only `{ directTools }` for a server into the same file.
|
|
125
|
+
* `true` exposes all tools directly, `false` hides them, a `string[]`
|
|
126
|
+
* exposes the named subset. Never copies credentials.
|
|
127
|
+
*/
|
|
128
|
+
export function writeServerDirectTools(cwd: string, serverName: string, value: true | false | string[]): void {
|
|
129
|
+
writeServerField(cwd, serverName, "directTools", value);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ── Project-shared file (<cwd>/.mcp.json) — server DEFINITIONS ──────────────
|
|
133
|
+
//
|
|
134
|
+
// Distinct from the Pi override file above: this is the project-shared
|
|
135
|
+
// `.mcp.json` (the layer `loadAllServerDefs` reads at working-dir precedence)
|
|
136
|
+
// where NEW server DEFINITIONS belong. Imported/known/scaffolded servers are
|
|
137
|
+
// merged IN here so they are discoverable by the normal config cascade.
|
|
138
|
+
//
|
|
139
|
+
// Read is tolerant (`//` comments / trailing comma / missing file → `{ mcpServers: {} }`)
|
|
140
|
+
// via `stripJsonComments`, but a file that doesn't parse is REFUSED (throw) for the
|
|
141
|
+
// same credential-safety reason as the override writer. The rewrite drops `//`
|
|
142
|
+
// comments in that file — an accepted trade-off: this file is machine-managed by
|
|
143
|
+
// the setup panel, so comment preservation is deliberately not enforced.
|
|
144
|
+
|
|
145
|
+
type McpProjectDoc = Record<string, unknown> & { mcpServers: Record<string, unknown> };
|
|
146
|
+
|
|
147
|
+
function projectConfigPath(cwd: string): string {
|
|
148
|
+
return join(cwd, ".mcp.json");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Read the project-shared `.mcp.json` into a doc object.
|
|
153
|
+
* Missing/empty file → `{ mcpServers: {} }`. Unparseable or wrongly-shaped
|
|
154
|
+
* file → throw (never clobber data we cannot understand).
|
|
155
|
+
*/
|
|
156
|
+
function readProjectDoc(cwd: string): McpProjectDoc {
|
|
157
|
+
const path = projectConfigPath(cwd);
|
|
158
|
+
if (!existsSync(path)) return { mcpServers: {} };
|
|
159
|
+
const raw = readFileSync(path, "utf-8");
|
|
160
|
+
if (raw.trim() === "") return { mcpServers: {} };
|
|
161
|
+
|
|
162
|
+
let parsed: unknown;
|
|
163
|
+
try {
|
|
164
|
+
parsed = JSON.parse(stripJsonComments(raw));
|
|
165
|
+
} catch (e) {
|
|
166
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
167
|
+
throw new Error(`[archimedes/mcp] Refusing to overwrite ${path} — unparseable JSON: ${msg}`);
|
|
168
|
+
}
|
|
169
|
+
if (!isPlainObject(parsed)) {
|
|
170
|
+
throw new Error(`[archimedes/mcp] Refusing to overwrite ${path} — top level is not an object`);
|
|
171
|
+
}
|
|
172
|
+
const mcpServers = parsed.mcpServers;
|
|
173
|
+
if (mcpServers !== undefined && !isPlainObject(mcpServers)) {
|
|
174
|
+
throw new Error(`[archimedes/mcp] Refusing to overwrite ${path} — "mcpServers" is not an object`);
|
|
175
|
+
}
|
|
176
|
+
return { ...parsed, mcpServers: isPlainObject(mcpServers) ? mcpServers : {} };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Atomic write of the project-shared file (shared writer, `projectConfigPath` target). */
|
|
180
|
+
function writeProjectDoc(cwd: string, doc: McpProjectDoc): void {
|
|
181
|
+
writeJsonFileAtomic(projectConfigPath(cwd), doc);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Merge new server definitions into `<cwd>/.mcp.json` (project-shared, NOT
|
|
186
|
+
* the Pi override file). Add-if-absent: an EXISTING entry for a given server
|
|
187
|
+
* name is left completely untouched; all other top-level keys and all other
|
|
188
|
+
* servers are preserved verbatim. Atomic tmp+rename, 2-space indent.
|
|
189
|
+
*/
|
|
190
|
+
export function mergeServerDefinitions(cwd: string, servers: Record<string, ServerDef>): void {
|
|
191
|
+
const doc = readProjectDoc(cwd);
|
|
192
|
+
for (const [name, def] of Object.entries(servers)) {
|
|
193
|
+
if (doc.mcpServers[name] === undefined) {
|
|
194
|
+
doc.mcpServers[name] = def;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
writeProjectDoc(cwd, doc);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The server names currently defined in `<cwd>/.mcp.json` (empty list when
|
|
202
|
+
* the file is absent or empty). Shares the same refuses-on-unparseable rule
|
|
203
|
+
* as the writers — a corrupt file surfaces as an error instead of a guess.
|
|
204
|
+
*/
|
|
205
|
+
export function existingProjectServerNames(cwd: string): string[] {
|
|
206
|
+
return Object.keys(readProjectDoc(cwd).mcpServers);
|
|
207
|
+
}
|
|
@@ -0,0 +1,468 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
|
|
2
|
+
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import type { McpConfig, ServerDef } from "./types.js";
|
|
6
|
+
import { DEFAULT_MCP_CONFIG } from "./types.js";
|
|
7
|
+
import {
|
|
8
|
+
stripJsonComments,
|
|
9
|
+
mergeServerDefs,
|
|
10
|
+
resolveServerSettings,
|
|
11
|
+
loadServerDefs,
|
|
12
|
+
loadAllServerDefs,
|
|
13
|
+
URL_BOUND_AUTH_FIELDS,
|
|
14
|
+
} from "./config.js";
|
|
15
|
+
|
|
16
|
+
const globalConfig: McpConfig = { ...DEFAULT_MCP_CONFIG };
|
|
17
|
+
|
|
18
|
+
const stdio = (extra: Record<string, unknown> = {}): ServerDef =>
|
|
19
|
+
({ type: "stdio", command: "node", ...extra }) as ServerDef;
|
|
20
|
+
|
|
21
|
+
const http = (extra: Record<string, unknown> = {}): ServerDef =>
|
|
22
|
+
({ type: "http", url: "http://example.com", ...extra }) as ServerDef;
|
|
23
|
+
|
|
24
|
+
describe("stripJsonComments", () => {
|
|
25
|
+
it("parses JSON with // comments and trailing commas", () => {
|
|
26
|
+
const raw = `{
|
|
27
|
+
// top-level comment
|
|
28
|
+
"mcpServers": {
|
|
29
|
+
"a": { "command": "node" }, // stdio server
|
|
30
|
+
"b": { "url": "http://x", "headers": { "X-Api": "k" } },
|
|
31
|
+
},
|
|
32
|
+
}`;
|
|
33
|
+
const parsed = JSON.parse(stripJsonComments(raw));
|
|
34
|
+
expect(parsed.mcpServers.a.command).toBe("node");
|
|
35
|
+
expect(parsed.mcpServers.b.url).toBe("http://x");
|
|
36
|
+
expect(parsed.mcpServers.b.headers).toEqual({ "X-Api": "k" });
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does not strip // inside string literals", () => {
|
|
40
|
+
const raw = `{ "url": "https://example.com/path", "args": ["a//b"] } // trailing comment`;
|
|
41
|
+
const parsed = JSON.parse(stripJsonComments(raw));
|
|
42
|
+
expect(parsed.url).toBe("https://example.com/path");
|
|
43
|
+
expect(parsed.args).toEqual(["a//b"]);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("strips block comments and keeps plain JSON unchanged", () => {
|
|
47
|
+
const raw = `{ /* block\ncomment */ "a": 1 }`;
|
|
48
|
+
const parsed = JSON.parse(stripJsonComments(raw));
|
|
49
|
+
expect(parsed.a).toBe(1);
|
|
50
|
+
const plain = `{"a": [1, 2, 3]}`;
|
|
51
|
+
expect(stripJsonComments(plain)).toBe(plain);
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
describe("URL_BOUND_AUTH_FIELDS", () => {
|
|
56
|
+
it("covers auth, headers, and bearerTokenEnv", () => {
|
|
57
|
+
expect([...URL_BOUND_AUTH_FIELDS].sort()).toEqual(["auth", "bearerTokenEnv", "headers"]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe("mergeServerDefs", () => {
|
|
62
|
+
it("higher layer overrides lower for the same server", () => {
|
|
63
|
+
const merged = mergeServerDefs([
|
|
64
|
+
{ a: stdio({ command: "old" }) },
|
|
65
|
+
{ a: stdio({ command: "new" }) },
|
|
66
|
+
]);
|
|
67
|
+
expect(merged.a).toMatchObject({ command: "new" });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("merges field-level: unspecified fields are inherited from lower layers", () => {
|
|
71
|
+
const merged = mergeServerDefs([
|
|
72
|
+
{ a: stdio({ command: "node", args: ["x.js"], env: { K: "v" } }) },
|
|
73
|
+
{ a: stdio({ command: "bun" }) },
|
|
74
|
+
]);
|
|
75
|
+
expect(merged.a).toEqual({ type: "stdio", command: "bun", args: ["x.js"], env: { K: "v" } });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("keeps a server present only in a lower layer", () => {
|
|
79
|
+
const merged = mergeServerDefs([
|
|
80
|
+
{ low: stdio({ command: "node" }) },
|
|
81
|
+
{ high: stdio({ command: "bun" }) },
|
|
82
|
+
]);
|
|
83
|
+
expect(merged.low).toMatchObject({ command: "node" });
|
|
84
|
+
expect(merged.high).toMatchObject({ command: "bun" });
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it("drops inherited auth/headers/bearerTokenEnv when a higher layer changes the url", () => {
|
|
88
|
+
const merged = mergeServerDefs([
|
|
89
|
+
{
|
|
90
|
+
s: http({
|
|
91
|
+
url: "http://old",
|
|
92
|
+
auth: { token: "secret" },
|
|
93
|
+
headers: { "X-Api": "k" },
|
|
94
|
+
bearerTokenEnv: "TOKEN",
|
|
95
|
+
}),
|
|
96
|
+
},
|
|
97
|
+
{ s: http({ url: "http://new" }) },
|
|
98
|
+
]);
|
|
99
|
+
const def = merged.s as ServerDef;
|
|
100
|
+
expect(def).toMatchObject({ url: "http://new" });
|
|
101
|
+
expect("auth" in def).toBe(false);
|
|
102
|
+
expect("headers" in def).toBe(false);
|
|
103
|
+
expect("bearerTokenEnv" in def).toBe(false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("keeps inherited auth fields when the url is unchanged", () => {
|
|
107
|
+
const merged = mergeServerDefs([
|
|
108
|
+
{ s: http({ url: "http://same", auth: { token: "secret" }, headers: { "X-Api": "k" } }) },
|
|
109
|
+
{ s: http({ url: "http://same", directTools: true }) },
|
|
110
|
+
]);
|
|
111
|
+
const def = merged.s as ServerDef;
|
|
112
|
+
expect(def).toMatchObject({
|
|
113
|
+
url: "http://same",
|
|
114
|
+
directTools: true,
|
|
115
|
+
auth: { token: "secret" },
|
|
116
|
+
headers: { "X-Api": "k" },
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("keeps inherited auth fields when the higher layer has no url", () => {
|
|
121
|
+
const merged = mergeServerDefs([
|
|
122
|
+
{ s: http({ url: "http://same", bearerTokenEnv: "TOKEN" }) },
|
|
123
|
+
{ s: { type: "http", idleTimeout: 5 } as ServerDef }, // no url in this layer
|
|
124
|
+
]);
|
|
125
|
+
expect(merged.s).toMatchObject({ url: "http://same", bearerTokenEnv: "TOKEN", idleTimeout: 5 });
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("keeps auth fields the higher layer specifies for the new url", () => {
|
|
129
|
+
const merged = mergeServerDefs([
|
|
130
|
+
{ s: http({ url: "http://old", auth: { token: "old-secret" } }) },
|
|
131
|
+
{ s: http({ url: "http://new", auth: { token: "new-secret" } }) },
|
|
132
|
+
]);
|
|
133
|
+
expect(merged.s).toMatchObject({ url: "http://new", auth: { token: "new-secret" } });
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
describe("resolveServerSettings", () => {
|
|
138
|
+
it("applies defaults when nothing is set per-server", () => {
|
|
139
|
+
const s = resolveServerSettings(stdio(), globalConfig);
|
|
140
|
+
expect(s.lifecycle).toBe("lazy");
|
|
141
|
+
expect(s.idleTimeout).toBe(globalConfig.idleTimeout);
|
|
142
|
+
expect(s.toolPrefix).toBe(globalConfig.toolPrefix);
|
|
143
|
+
expect(s.directTools).toBe(globalConfig.directTools);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("per-server idleTimeout wins over global", () => {
|
|
147
|
+
const s = resolveServerSettings(stdio({ idleTimeout: 5 }), globalConfig);
|
|
148
|
+
expect(s.idleTimeout).toBe(5);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it("per-server idleTimeout of 0 is honored (disables) rather than falling back to global", () => {
|
|
152
|
+
const s = resolveServerSettings(stdio({ idleTimeout: 0 }), globalConfig);
|
|
153
|
+
expect(s.idleTimeout).toBe(0);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("global toolPrefix is used when per-server is absent, per-server wins when present", () => {
|
|
157
|
+
expect(resolveServerSettings(stdio(), globalConfig).toolPrefix).toBe(globalConfig.toolPrefix);
|
|
158
|
+
const custom: McpConfig = { ...globalConfig, toolPrefix: "mcp" };
|
|
159
|
+
expect(resolveServerSettings(stdio(), custom).toolPrefix).toBe("mcp");
|
|
160
|
+
expect(resolveServerSettings(stdio({ toolPrefix: "none" }), custom).toolPrefix).toBe("none");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
it("per-server directTools wins over global", () => {
|
|
164
|
+
expect(resolveServerSettings(stdio({ directTools: ["t1"] }), globalConfig).directTools).toEqual(["t1"]);
|
|
165
|
+
expect(resolveServerSettings(stdio({ directTools: false }), globalConfig).directTools).toBe(false);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it("passes through includeTools/excludeTools/requestTimeoutMs/exposeResources/debug/protocolVersion", () => {
|
|
169
|
+
const s = resolveServerSettings(
|
|
170
|
+
stdio({
|
|
171
|
+
includeTools: ["a"],
|
|
172
|
+
excludeTools: ["b"],
|
|
173
|
+
requestTimeoutMs: 5000,
|
|
174
|
+
exposeResources: true,
|
|
175
|
+
debug: true,
|
|
176
|
+
protocolVersion: "2025-06-18",
|
|
177
|
+
}),
|
|
178
|
+
globalConfig,
|
|
179
|
+
);
|
|
180
|
+
expect(s.includeTools).toEqual(["a"]);
|
|
181
|
+
expect(s.excludeTools).toEqual(["b"]);
|
|
182
|
+
expect(s.requestTimeoutMs).toBe(5000);
|
|
183
|
+
expect(s.exposeResources).toBe(true);
|
|
184
|
+
expect(s.debug).toBe(true);
|
|
185
|
+
expect(s.protocolVersion).toBe("2025-06-18");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("omits passthrough fields when not set per-server", () => {
|
|
189
|
+
const s = resolveServerSettings(stdio(), globalConfig);
|
|
190
|
+
expect("includeTools" in s).toBe(false);
|
|
191
|
+
expect("excludeTools" in s).toBe(false);
|
|
192
|
+
expect("requestTimeoutMs" in s).toBe(false);
|
|
193
|
+
expect("exposeResources" in s).toBe(false);
|
|
194
|
+
expect("debug" in s).toBe(false);
|
|
195
|
+
expect("protocolVersion" in s).toBe(false);
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
describe("loadServerDefs (integration with temp dirs)", () => {
|
|
200
|
+
let home: string;
|
|
201
|
+
let agentDir: string;
|
|
202
|
+
let wd: string;
|
|
203
|
+
|
|
204
|
+
beforeEach(() => {
|
|
205
|
+
home = mkdtempSync(join(tmpdir(), "mcp-home-"));
|
|
206
|
+
agentDir = mkdtempSync(join(tmpdir(), "mcp-agent-"));
|
|
207
|
+
wd = mkdtempSync(join(tmpdir(), "mcp-wd-"));
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
afterEach(() => {
|
|
211
|
+
for (const dir of [home, agentDir, wd]) {
|
|
212
|
+
rmSync(dir, { recursive: true, force: true });
|
|
213
|
+
}
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const write = (dir: string, rel: string, content: string): void => {
|
|
217
|
+
const p = join(dir, rel);
|
|
218
|
+
mkdirSync(join(p, ".."), { recursive: true });
|
|
219
|
+
writeFileSync(p, content);
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
it("loads all six precedence layers in order, with comments and url-binding applied", () => {
|
|
223
|
+
// 1. ~/.config/mcp/mcp.json (lowest)
|
|
224
|
+
write(
|
|
225
|
+
home,
|
|
226
|
+
join(".config", "mcp", "mcp.json"),
|
|
227
|
+
JSON.stringify({
|
|
228
|
+
mcpServers: {
|
|
229
|
+
svc: { type: "http", url: "http://v1", auth: { token: "tok" } },
|
|
230
|
+
onlyLow: { type: "stdio", command: "node" },
|
|
231
|
+
},
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
// 2. ~/.agents/mcp.json — same url, adds a field (auth must survive)
|
|
235
|
+
write(
|
|
236
|
+
home,
|
|
237
|
+
join(".agents", "mcp.json"),
|
|
238
|
+
JSON.stringify({
|
|
239
|
+
mcpServers: { svc: { type: "http", url: "http://v1", directTools: true } },
|
|
240
|
+
}),
|
|
241
|
+
);
|
|
242
|
+
// 3. ~/.agents/mcp/mcp.json — url changes: inherited auth must be dropped
|
|
243
|
+
write(
|
|
244
|
+
home,
|
|
245
|
+
join(".agents", "mcp", "mcp.json"),
|
|
246
|
+
JSON.stringify({ mcpServers: { svc: { type: "http", url: "http://v2" } } }),
|
|
247
|
+
);
|
|
248
|
+
// 4. <agentDir>/mcp.json — with // comments and trailing commas
|
|
249
|
+
write(
|
|
250
|
+
agentDir,
|
|
251
|
+
"mcp.json",
|
|
252
|
+
`{
|
|
253
|
+
// agent dir config
|
|
254
|
+
"mcpServers": {
|
|
255
|
+
"svc": { "type": "http", "url": "http://v2" }, // unchanged url
|
|
256
|
+
},
|
|
257
|
+
}`,
|
|
258
|
+
);
|
|
259
|
+
// 5. <cwd>/.mcp.json — adds a new server
|
|
260
|
+
write(
|
|
261
|
+
wd,
|
|
262
|
+
".mcp.json",
|
|
263
|
+
JSON.stringify({ mcpServers: { local: { type: "stdio", command: "bun" } } }),
|
|
264
|
+
);
|
|
265
|
+
// 6. <cwd>/.pi/mcp.json (highest) — per-server setting override
|
|
266
|
+
write(
|
|
267
|
+
wd,
|
|
268
|
+
join(".pi", "mcp.json"),
|
|
269
|
+
JSON.stringify({ mcpServers: { svc: { type: "http", url: "http://v2", idleTimeout: 3 } } }),
|
|
270
|
+
);
|
|
271
|
+
|
|
272
|
+
const defs = loadServerDefs(wd, { homeDir: home, agentDir });
|
|
273
|
+
|
|
274
|
+
const svc = defs["svc"];
|
|
275
|
+
expect(svc).toMatchObject({ type: "http", url: "http://v2", directTools: true, idleTimeout: 3 });
|
|
276
|
+
expect("auth" in (svc ?? {})).toBe(false);
|
|
277
|
+
expect(defs["onlyLow"]).toMatchObject({ command: "node" });
|
|
278
|
+
expect(defs["local"]).toMatchObject({ command: "bun" });
|
|
279
|
+
expect(Object.keys(defs).sort()).toEqual(["local", "onlyLow", "svc"]);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("excludes disabled servers and still honors disabled from a higher layer", () => {
|
|
283
|
+
write(
|
|
284
|
+
wd,
|
|
285
|
+
".mcp.json",
|
|
286
|
+
JSON.stringify({
|
|
287
|
+
mcpServers: {
|
|
288
|
+
off: { type: "stdio", command: "node", disabled: true },
|
|
289
|
+
on: { type: "stdio", command: "node" },
|
|
290
|
+
},
|
|
291
|
+
}),
|
|
292
|
+
);
|
|
293
|
+
write(
|
|
294
|
+
home,
|
|
295
|
+
join(".config", "mcp", "mcp.json"),
|
|
296
|
+
JSON.stringify({ mcpServers: { off: { type: "stdio", command: "node" } } }),
|
|
297
|
+
);
|
|
298
|
+
const defs = loadServerDefs(wd, { homeDir: home, agentDir });
|
|
299
|
+
expect("off" in defs).toBe(false);
|
|
300
|
+
expect(defs["on"]).toMatchObject({ command: "node" });
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
it("loadAllServerDefs keeps disabled servers (flag intact) while loadServerDefs excludes them", () => {
|
|
304
|
+
write(
|
|
305
|
+
wd,
|
|
306
|
+
".mcp.json",
|
|
307
|
+
JSON.stringify({
|
|
308
|
+
mcpServers: {
|
|
309
|
+
off: { type: "stdio", command: "node", disabled: true },
|
|
310
|
+
on: { type: "stdio", command: "node" },
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
);
|
|
314
|
+
write(
|
|
315
|
+
home,
|
|
316
|
+
join(".config", "mcp", "mcp.json"),
|
|
317
|
+
JSON.stringify({ mcpServers: { off: { type: "stdio", command: "node" } } }),
|
|
318
|
+
);
|
|
319
|
+
const all = loadAllServerDefs(wd, { homeDir: home, agentDir });
|
|
320
|
+
expect("off" in all).toBe(true);
|
|
321
|
+
expect(all["off"]).toMatchObject({ command: "node", disabled: true });
|
|
322
|
+
expect(all["on"]).toMatchObject({ command: "node" });
|
|
323
|
+
const active = loadServerDefs(wd, { homeDir: home, agentDir });
|
|
324
|
+
expect("off" in active).toBe(false);
|
|
325
|
+
expect(active["on"]).toMatchObject({ command: "node" });
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
describe("auth-type warning", () => {
|
|
329
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
330
|
+
|
|
331
|
+
beforeEach(() => {
|
|
332
|
+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
afterEach(() => {
|
|
336
|
+
warnSpy.mockRestore();
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('does not warn for auth: "oauth" (string)', () => {
|
|
340
|
+
write(
|
|
341
|
+
wd,
|
|
342
|
+
".mcp.json",
|
|
343
|
+
JSON.stringify({
|
|
344
|
+
mcpServers: { svc: { type: "http", url: "http://x", auth: "oauth" } },
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
const defs = loadServerDefs(wd, { homeDir: home, agentDir });
|
|
348
|
+
expect(defs["svc"]).toMatchObject({ url: "http://x", auth: "oauth" });
|
|
349
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it("does not warn for bearer { token }, an OAuth config, or a single known field", () => {
|
|
353
|
+
write(
|
|
354
|
+
wd,
|
|
355
|
+
".mcp.json",
|
|
356
|
+
JSON.stringify({
|
|
357
|
+
mcpServers: {
|
|
358
|
+
bearer: { type: "http", url: "http://x", auth: { token: "tok" } },
|
|
359
|
+
cfg: {
|
|
360
|
+
type: "http",
|
|
361
|
+
url: "http://y",
|
|
362
|
+
auth: { grantType: "client_credentials", clientId: "fixed-client" },
|
|
363
|
+
},
|
|
364
|
+
single: { type: "http", url: "http://z", auth: { clientId: "fixed-client" } },
|
|
365
|
+
},
|
|
366
|
+
}),
|
|
367
|
+
);
|
|
368
|
+
const defs = loadServerDefs(wd, { homeDir: home, agentDir });
|
|
369
|
+
expect(Object.keys(defs).sort()).toEqual(["bearer", "cfg", "single"]);
|
|
370
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
it("warns (but keeps) servers whose auth object has no known fields, even with a non-string token", () => {
|
|
374
|
+
write(
|
|
375
|
+
wd,
|
|
376
|
+
".mcp.json",
|
|
377
|
+
JSON.stringify({
|
|
378
|
+
mcpServers: {
|
|
379
|
+
garbage: { type: "http", url: "http://x", auth: { foo: 1 } },
|
|
380
|
+
tokNum: { type: "http", url: "http://y", auth: { token: 5 } },
|
|
381
|
+
},
|
|
382
|
+
}),
|
|
383
|
+
);
|
|
384
|
+
const defs = loadServerDefs(wd, { homeDir: home, agentDir });
|
|
385
|
+
expect(defs["garbage"]).toMatchObject({ url: "http://x" });
|
|
386
|
+
expect(defs["tokNum"]).toMatchObject({ url: "http://y" });
|
|
387
|
+
expect(warnSpy).toHaveBeenCalledTimes(2);
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
it("warns (but keeps) servers with unknown auth shapes (other strings, numbers)", () => {
|
|
391
|
+
write(
|
|
392
|
+
wd,
|
|
393
|
+
".mcp.json",
|
|
394
|
+
JSON.stringify({
|
|
395
|
+
mcpServers: {
|
|
396
|
+
bad: { type: "http", url: "http://x", auth: "azure-ad" },
|
|
397
|
+
num: { type: "http", url: "http://y", auth: 42 },
|
|
398
|
+
},
|
|
399
|
+
}),
|
|
400
|
+
);
|
|
401
|
+
const defs = loadServerDefs(wd, { homeDir: home, agentDir });
|
|
402
|
+
expect(defs["bad"]).toMatchObject({ url: "http://x" });
|
|
403
|
+
expect(defs["num"]).toMatchObject({ url: "http://y" });
|
|
404
|
+
expect(warnSpy).toHaveBeenCalledTimes(2);
|
|
405
|
+
expect(warnSpy.mock.calls.map((c) => c[0]).join("\n")).toContain("\"azure-ad\"");
|
|
406
|
+
});
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
describe("shape-based classification and mangled defs", () => {
|
|
410
|
+
let warnSpy: ReturnType<typeof vi.spyOn>;
|
|
411
|
+
|
|
412
|
+
beforeEach(() => {
|
|
413
|
+
warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
414
|
+
});
|
|
415
|
+
|
|
416
|
+
afterEach(() => {
|
|
417
|
+
warnSpy.mockRestore();
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
it("loads a url server without a type field and does not warn", () => {
|
|
421
|
+
write(
|
|
422
|
+
wd,
|
|
423
|
+
".mcp.json",
|
|
424
|
+
JSON.stringify({
|
|
425
|
+
mcpServers: {
|
|
426
|
+
api: { url: "https://api.example/mcp", headers: { Authorization: "Bearer x" } },
|
|
427
|
+
},
|
|
428
|
+
}),
|
|
429
|
+
);
|
|
430
|
+
const defs = loadServerDefs(wd, { homeDir: home, agentDir });
|
|
431
|
+
expect(defs["api"]).toMatchObject({
|
|
432
|
+
url: "https://api.example/mcp",
|
|
433
|
+
headers: { Authorization: "Bearer x" },
|
|
434
|
+
});
|
|
435
|
+
expect(warnSpy).not.toHaveBeenCalled();
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
it("skips a def with neither a url nor a command and warns", () => {
|
|
439
|
+
write(
|
|
440
|
+
wd,
|
|
441
|
+
".mcp.json",
|
|
442
|
+
JSON.stringify({
|
|
443
|
+
mcpServers: { broken: { foo: 1 }, ok: { url: "http://x" } },
|
|
444
|
+
}),
|
|
445
|
+
);
|
|
446
|
+
const defs = loadServerDefs(wd, { homeDir: home, agentDir });
|
|
447
|
+
expect("broken" in defs).toBe(false);
|
|
448
|
+
expect(defs["ok"]).toMatchObject({ url: "http://x" });
|
|
449
|
+
expect(warnSpy).toHaveBeenCalledTimes(1);
|
|
450
|
+
const msg = warnSpy.mock.calls[0]?.[0] as string;
|
|
451
|
+
expect(msg).toContain('Server "broken"');
|
|
452
|
+
expect(msg).toContain("url");
|
|
453
|
+
expect(msg).toContain("command");
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
it("loadAllServerDefs keeps mangled defs intact (flag-intact principle)", () => {
|
|
457
|
+
write(
|
|
458
|
+
wd,
|
|
459
|
+
".mcp.json",
|
|
460
|
+
JSON.stringify({
|
|
461
|
+
mcpServers: { broken: { foo: 1 } },
|
|
462
|
+
}),
|
|
463
|
+
);
|
|
464
|
+
const all = loadAllServerDefs(wd, { homeDir: home, agentDir });
|
|
465
|
+
expect(all["broken"]).toMatchObject({ foo: 1 });
|
|
466
|
+
});
|
|
467
|
+
});
|
|
468
|
+
});
|