* feat(cli): lift config-file headers/timeouts/OAuth into serverSettings (#1482) The CLI resolved file-sourced servers via resolveServerConfigs(), which returns bare MCPServerConfig and dropped disk-level headers, timeouts, and OAuth — CLI connections from a --catalog/--config file silently ignored them. The TUI already lifted these via mcpConfigToServerEntries(). Extract the TUI's catalog/config -> per-server-settings resolution into a shared core module (core/mcp/node/servers.ts: loadServerEntries + selectServerEntry) and route both CLI and TUI through it, so the two share one file->settings path. --header still overrides the file's headers for the run while preserving the file's timeouts/OAuth. - core: add loadServerEntries/selectServerEntry/headersToServerSettings - tui: tui-servers.ts re-exports the shared core helpers (no logic dup) - cli: resolve via loadServerEntries + selectServerEntry; drop the CLI-only conflict check and bare-config path - tests: core servers.test.ts (settings lifting, header merge/override, stdio overrides, seed/error/conflict, selection); CLI integration tests asserting a config header is sent over HTTP and --header overrides it - docs: CLI README note on file settings being applied Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(core): address #1500 review — dedup overrides, drop dead wrapper, clearer error, spec refresh - Remove the now-unused resolveLaunchServerConfigs() wrapper (both CLI and TUI route through loadServerEntries now); migrate its default-catalog launch tests to compose withDefaultCatalogPath + resolveServerConfigs. - Deduplicate applyStdioOverrides against config.ts applyOverrides: export the latter (now trims cwd) and reuse it in servers.ts. - selectServerEntry's unknown-name error is source-agnostic ("not found" not "not found in config file") since entries may be a single ad-hoc default, where "config file" would mislead. - Refresh specification/ docs (v2_cli_tui_launcher.md, v2_catalog_launch_config.md): CLI/TUI resolution now via loadServerEntries/selectServerEntry; mark gap G1 resolved (#1482); withDefaultConfigPath → withDefaultCatalogPath. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * test(cli): extend smoke:cli to cover #1482 lift→serverSettings Adds end-to-end smoke coverage for the config-file headers/timeouts/OAuth lift introduced in #1482: - unknown --server now errors source-agnostically ("not found") - multi-server catalog selection via --server (and the omit-it error) - --header merge does not break the stdio connect path - HTTP transport: a config-file `headers` object is lifted onto the wire, and a CLI --header overrides it (verified against an in-process test server's recorded requests via a non-blocking spawn) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
73b651ff21
commit
e72d4307f3
6 changed files with 1033 additions and 420 deletions
|
|
@ -78,6 +78,8 @@ npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --tr
|
|||
npx @modelcontextprotocol/inspector --cli https://my-mcp-server.example.com --transport http --method tools/list --header "X-API-Key: your-api-key"
|
||||
```
|
||||
|
||||
When a server is loaded from a `--catalog`/`--config` file, its per-server settings (headers, connection/request timeouts, and OAuth) are applied to the connection — the same resolution the TUI uses. A `--header` flag overrides the file's headers for that run while leaving the file's timeouts and OAuth in place.
|
||||
|
||||
## Options
|
||||
|
||||
### MCP server (which server to connect to)
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ import {
|
|||
createEchoTool,
|
||||
createTestServerInfo,
|
||||
} from "@modelcontextprotocol/inspector-test-server";
|
||||
import type { MCPServerConfig } from "@modelcontextprotocol/inspector-core/mcp/index.js";
|
||||
|
||||
describe("CLI Tests", () => {
|
||||
describe("Basic CLI Mode", () => {
|
||||
|
|
@ -320,6 +321,100 @@ describe("CLI Tests", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("Config-file settings lifting (#1482)", () => {
|
||||
it("applies a config file's custom header on a tools/call over HTTP", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
let configPath: string | undefined;
|
||||
try {
|
||||
await server.start();
|
||||
// Write a config whose server carries disk-level headers + a timeout.
|
||||
// `headers`/`requestTimeout` are Inspector-extension fields not present
|
||||
// on MCPServerConfig, so build the entry as a loose record.
|
||||
configPath = createTestConfig({
|
||||
mcpServers: {
|
||||
web: {
|
||||
type: "streamable-http",
|
||||
url: server.url,
|
||||
headers: { "X-Custom-Token": "secret-123" },
|
||||
requestTimeout: 8000,
|
||||
} as unknown as MCPServerConfig,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--server",
|
||||
"web",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/call",
|
||||
"--tool-name",
|
||||
"echo",
|
||||
"--tool-arg",
|
||||
"message=hi",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
// The server should have received a request carrying the disk header
|
||||
// (Express lower-cases header names), proving the CLI lifted it from
|
||||
// the config file into the connection's settings.
|
||||
const sawHeader = server
|
||||
.getRecordedRequests()
|
||||
.some((r) => r.headers["x-custom-token"] === "secret-123");
|
||||
expect(sawHeader).toBe(true);
|
||||
} finally {
|
||||
await server.stop();
|
||||
if (configPath) deleteConfigFile(configPath);
|
||||
}
|
||||
});
|
||||
|
||||
it("overrides a config file's header with --header", async () => {
|
||||
const server = createTestServerHttp({
|
||||
serverInfo: createTestServerInfo(),
|
||||
tools: [createEchoTool()],
|
||||
});
|
||||
let configPath: string | undefined;
|
||||
try {
|
||||
await server.start();
|
||||
configPath = createTestConfig({
|
||||
mcpServers: {
|
||||
web: {
|
||||
type: "streamable-http",
|
||||
url: server.url,
|
||||
headers: { "X-Custom-Token": "from-disk" },
|
||||
} as unknown as MCPServerConfig,
|
||||
},
|
||||
});
|
||||
|
||||
const result = await runCli([
|
||||
"--config",
|
||||
configPath,
|
||||
"--server",
|
||||
"web",
|
||||
"--header",
|
||||
"X-Custom-Token: from-cli",
|
||||
"--cli",
|
||||
"--method",
|
||||
"tools/list",
|
||||
]);
|
||||
|
||||
expectCliSuccess(result);
|
||||
const tokens = server
|
||||
.getRecordedRequests()
|
||||
.map((r) => r.headers["x-custom-token"]);
|
||||
expect(tokens).toContain("from-cli");
|
||||
expect(tokens).not.toContain("from-disk");
|
||||
} finally {
|
||||
await server.stop();
|
||||
if (configPath) deleteConfigFile(configPath);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("Resource Options", () => {
|
||||
it("should read resource with URI", async () => {
|
||||
const { command, args } = getTestMcpServerCommand();
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import type {
|
|||
InspectorServerSettings,
|
||||
MCPServerConfig,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import { DEFAULT_TASK_TTL_MS } from "@inspector/core/mcp/types.js";
|
||||
import { InspectorClient } from "@inspector/core/mcp/index.js";
|
||||
import {
|
||||
ManagedToolsState,
|
||||
|
|
@ -18,9 +17,8 @@ import {
|
|||
} from "@inspector/core/mcp/state/index.js";
|
||||
import {
|
||||
createTransportNode,
|
||||
resolveLaunchServerConfigs,
|
||||
serverSourceConflict,
|
||||
hasAdHocServerOptions,
|
||||
loadServerEntries,
|
||||
selectServerEntry,
|
||||
parseKeyValuePair as parseEnvPair,
|
||||
parseHeaderPair,
|
||||
} from "@inspector/core/mcp/node/index.js";
|
||||
|
|
@ -46,23 +44,6 @@ type MethodArgs = {
|
|||
metadata?: Record<string, string>;
|
||||
};
|
||||
|
||||
function headersToServerSettings(
|
||||
headers?: Record<string, string>,
|
||||
): InspectorServerSettings | undefined {
|
||||
if (!headers || Object.keys(headers).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
headers: Object.entries(headers).map(([key, value]) => ({ key, value })),
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: DEFAULT_TASK_TTL_MS,
|
||||
autoRefreshOnListChanged: false,
|
||||
roots: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function callMethod(
|
||||
serverConfig: MCPServerConfig,
|
||||
serverSettings: InspectorServerSettings | undefined,
|
||||
|
|
@ -397,30 +378,24 @@ function parseArgs(argv?: string[]): {
|
|||
// back to MCP_CATALOG_PATH — keeps CLI and TUI flag resolution identical.
|
||||
catalogPath: options.catalog?.trim() || process.env.MCP_CATALOG_PATH,
|
||||
configPath: options.config?.trim() || undefined,
|
||||
serverName: options.server,
|
||||
target: targetArgs.length > 0 ? targetArgs : undefined,
|
||||
transport: options.transport,
|
||||
serverUrl: options.serverUrl,
|
||||
cwd: options.cwd,
|
||||
env: options.e,
|
||||
// `--header` is merged into the resolved server's settings (overriding any
|
||||
// file-level headers); file timeouts/OAuth are preserved. See #1482.
|
||||
headers: options.header,
|
||||
};
|
||||
|
||||
const conflict = serverSourceConflict({
|
||||
hasCatalog: Boolean(serverOptions.catalogPath?.trim()),
|
||||
hasConfig: Boolean(serverOptions.configPath?.trim()),
|
||||
hasAdHoc: hasAdHocServerOptions(serverOptions),
|
||||
});
|
||||
if (conflict) {
|
||||
throw new Error(conflict);
|
||||
}
|
||||
|
||||
const configs = resolveLaunchServerConfigs(serverOptions, "single");
|
||||
const serverConfig = configs[0];
|
||||
if (!serverConfig) {
|
||||
throw new Error(
|
||||
"Could not resolve server config. Specify a URL or command, or use --config and --server.",
|
||||
);
|
||||
}
|
||||
// Shared with the TUI: resolves the catalog/config source (or ad-hoc target),
|
||||
// enforces the conflict matrix, and lifts disk headers/timeouts/OAuth into
|
||||
// per-server settings. `--server` selects one when the file has several.
|
||||
const entries = loadServerEntries(serverOptions);
|
||||
const { config: serverConfig, settings: serverSettings } = selectServerEntry(
|
||||
entries,
|
||||
options.server,
|
||||
);
|
||||
|
||||
if (!options.method) {
|
||||
throw new Error(
|
||||
|
|
@ -456,7 +431,7 @@ function parseArgs(argv?: string[]): {
|
|||
|
||||
return {
|
||||
serverConfig,
|
||||
serverSettings: headersToServerSettings(options.header),
|
||||
serverSettings,
|
||||
methodArgs,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,112 +1,12 @@
|
|||
import type {
|
||||
InspectorServerSettings,
|
||||
MCPServerConfig,
|
||||
StdioServerConfig,
|
||||
} from "@inspector/core/mcp/types.js";
|
||||
import { DEFAULT_TASK_TTL_MS } from "@inspector/core/mcp/types.js";
|
||||
import { mcpConfigToServerEntries } from "@inspector/core/mcp/serverList.js";
|
||||
import {
|
||||
resolveServerConfigs,
|
||||
resolveServerSource,
|
||||
serverSourceConflict,
|
||||
readServerListFile,
|
||||
hasAdHocServerOptions,
|
||||
withDefaultCatalogPath,
|
||||
type ServerConfigOptions,
|
||||
} from "@inspector/core/mcp/node/config.js";
|
||||
// The catalog/config → per-server-settings resolution is shared with the CLI in
|
||||
// `@inspector/core/mcp/node` (see issue #1482). This module just re-exports it
|
||||
// under the TUI's historical names so the TUI keeps a single import surface.
|
||||
import type { ResolvedServer } from "@inspector/core/mcp/node/servers.js";
|
||||
|
||||
export type TuiServer = {
|
||||
config: MCPServerConfig;
|
||||
settings?: InspectorServerSettings;
|
||||
};
|
||||
export {
|
||||
headersToServerSettings,
|
||||
loadServerEntries as loadTuiServers,
|
||||
} from "@inspector/core/mcp/node/servers.js";
|
||||
|
||||
export function headersToServerSettings(
|
||||
headers?: Record<string, string>,
|
||||
): InspectorServerSettings | undefined {
|
||||
if (!headers || Object.keys(headers).length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
headers: Object.entries(headers).map(([key, value]) => ({ key, value })),
|
||||
metadata: [],
|
||||
connectionTimeout: 0,
|
||||
requestTimeout: 0,
|
||||
taskTtl: DEFAULT_TASK_TTL_MS,
|
||||
autoRefreshOnListChanged: false,
|
||||
roots: [],
|
||||
};
|
||||
}
|
||||
|
||||
function applyStdioOverrides(
|
||||
config: MCPServerConfig,
|
||||
overrides: { env?: Record<string, string>; cwd?: string },
|
||||
): MCPServerConfig {
|
||||
if (config.type !== "stdio") return config;
|
||||
const c = { ...config } as StdioServerConfig;
|
||||
if (overrides.env && Object.keys(overrides.env).length > 0) {
|
||||
c.env = { ...(c.env ?? {}), ...overrides.env };
|
||||
}
|
||||
if (overrides.cwd?.trim()) {
|
||||
c.cwd = overrides.cwd.trim();
|
||||
}
|
||||
return c;
|
||||
}
|
||||
|
||||
function mergeSettings(
|
||||
base: InspectorServerSettings | undefined,
|
||||
headers?: Record<string, string>,
|
||||
): InspectorServerSettings | undefined {
|
||||
const fromHeaders = headersToServerSettings(headers);
|
||||
if (!fromHeaders) return base;
|
||||
if (!base) return fromHeaders;
|
||||
return { ...base, headers: fromHeaders.headers };
|
||||
}
|
||||
|
||||
export function loadTuiServers(
|
||||
serverOptions: ServerConfigOptions & { headers?: Record<string, string> },
|
||||
): Record<string, TuiServer> {
|
||||
serverOptions = withDefaultCatalogPath(serverOptions);
|
||||
|
||||
const conflict = serverSourceConflict({
|
||||
hasCatalog: Boolean(serverOptions.catalogPath?.trim()),
|
||||
hasConfig: Boolean(serverOptions.configPath?.trim()),
|
||||
hasAdHoc: hasAdHocServerOptions(serverOptions),
|
||||
});
|
||||
if (conflict) {
|
||||
throw new Error(conflict);
|
||||
}
|
||||
|
||||
const source = resolveServerSource(serverOptions);
|
||||
|
||||
if (source) {
|
||||
const config = readServerListFile(source.path, source.writable);
|
||||
const entries = mcpConfigToServerEntries(config);
|
||||
const result: Record<string, TuiServer> = {};
|
||||
for (const entry of entries) {
|
||||
result[entry.name] = {
|
||||
config: applyStdioOverrides(entry.config, {
|
||||
env: serverOptions.env,
|
||||
cwd: serverOptions.cwd,
|
||||
}),
|
||||
// Deliberate broadcast: a single `--header` set is merged into EVERY
|
||||
// server in the catalog/config (fine for the common single-server case;
|
||||
// for multi-server files, prefer per-server headers in the file itself).
|
||||
settings: mergeSettings(entry.settings, serverOptions.headers),
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
const configs = resolveServerConfigs(serverOptions, "multi");
|
||||
if (configs.length === 0) {
|
||||
throw new Error(
|
||||
"At least one server is required. Use --catalog/--config <path> or an ad-hoc target (command/URL).",
|
||||
);
|
||||
}
|
||||
return {
|
||||
default: {
|
||||
config: configs[0]!,
|
||||
settings: headersToServerSettings(serverOptions.headers),
|
||||
},
|
||||
};
|
||||
}
|
||||
/** A server resolved for the TUI: transport config plus per-server settings. */
|
||||
export type TuiServer = ResolvedServer;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ import {
|
|||
parseHeaderPair,
|
||||
withDefaultCatalogPath,
|
||||
resolveServerConfigs,
|
||||
resolveLaunchServerConfigs,
|
||||
resolveServerSource,
|
||||
serverSourceConflict,
|
||||
readServerListFile,
|
||||
|
|
@ -243,9 +242,17 @@ describe("readServerListFile", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("resolveLaunchServerConfigs", () => {
|
||||
describe("default-catalog launch resolution (withDefaultCatalogPath + resolveServerConfigs)", () => {
|
||||
// The CLI/TUI launch path: apply the default writable catalog when no source
|
||||
// or ad-hoc target is given, then resolve. (Both clients now compose these
|
||||
// via loadServerEntries; this exercises the underlying config primitives.)
|
||||
let tempDir: string;
|
||||
|
||||
const resolveLaunch = (
|
||||
options: Parameters<typeof resolveServerConfigs>[0],
|
||||
mode: Parameters<typeof resolveServerConfigs>[1],
|
||||
) => resolveServerConfigs(withDefaultCatalogPath(options), mode);
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "inspector-config-test-"));
|
||||
});
|
||||
|
|
@ -261,9 +268,9 @@ describe("resolveLaunchServerConfigs", () => {
|
|||
process.env.HOME = homeDir;
|
||||
const defaultConfig = join(homeDir, ".mcp-inspector", "mcp.json");
|
||||
try {
|
||||
expect(() =>
|
||||
resolveLaunchServerConfigs({ target: [] }, "single"),
|
||||
).toThrow(/No servers found/);
|
||||
expect(() => resolveLaunch({ target: [] }, "single")).toThrow(
|
||||
/No servers found/,
|
||||
);
|
||||
// The writable default catalog is seeded on first run (matches web),
|
||||
// rather than erroring with "Config file not found".
|
||||
expect(existsSync(defaultConfig)).toBe(true);
|
||||
|
|
@ -281,7 +288,7 @@ describe("resolveLaunchServerConfigs", () => {
|
|||
mkdirSync(homeDir, { recursive: true });
|
||||
process.env.HOME = homeDir;
|
||||
try {
|
||||
expect(resolveLaunchServerConfigs({}, "multi")).toEqual([]);
|
||||
expect(resolveLaunch({}, "multi")).toEqual([]);
|
||||
expect(existsSync(join(homeDir, ".mcp-inspector", "mcp.json"))).toBe(
|
||||
true,
|
||||
);
|
||||
|
|
@ -302,7 +309,7 @@ describe("resolveLaunchServerConfigs", () => {
|
|||
);
|
||||
process.env.HOME = homeDir;
|
||||
try {
|
||||
const configs = resolveLaunchServerConfigs({}, "multi");
|
||||
const configs = resolveLaunch({}, "multi");
|
||||
expect(configs).toHaveLength(1);
|
||||
expect(configs[0]).toMatchObject({ type: "stdio", command: "a" });
|
||||
} finally {
|
||||
|
|
|
|||
240
clients/web/src/test/core/mcp/node/servers.test.ts
Normal file
240
clients/web/src/test/core/mcp/node/servers.test.ts
Normal file
|
|
@ -0,0 +1,240 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
||||
import {
|
||||
existsSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
headersToServerSettings,
|
||||
loadServerEntries,
|
||||
selectServerEntry,
|
||||
type ResolvedServer,
|
||||
} from "@inspector/core/mcp/node/servers.js";
|
||||
|
||||
describe("headersToServerSettings", () => {
|
||||
it("returns undefined when no headers are given", () => {
|
||||
expect(headersToServerSettings()).toBeUndefined();
|
||||
expect(headersToServerSettings({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("builds a settings object carrying the headers as key/value pairs", () => {
|
||||
const settings = headersToServerSettings({ Authorization: "Bearer t" });
|
||||
expect(settings?.headers).toEqual([
|
||||
{ key: "Authorization", value: "Bearer t" },
|
||||
]);
|
||||
expect(settings?.metadata).toEqual([]);
|
||||
expect(settings?.roots).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadServerEntries", () => {
|
||||
let tempDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "server-entries-test-"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("lifts disk headers, timeouts, and OAuth into per-server settings", () => {
|
||||
const configPath = join(tempDir, "mcp.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
web: {
|
||||
type: "streamable-http",
|
||||
url: "http://x/mcp",
|
||||
headers: { Authorization: "Bearer disk" },
|
||||
connectionTimeout: 5000,
|
||||
requestTimeout: 9000,
|
||||
oauth: {
|
||||
clientId: "client-abc",
|
||||
clientSecret: "shh",
|
||||
scopes: ["a", "b"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const servers = loadServerEntries({ configPath });
|
||||
const settings = servers.web?.settings;
|
||||
expect(settings?.headers).toEqual([
|
||||
{ key: "Authorization", value: "Bearer disk" },
|
||||
]);
|
||||
expect(settings?.connectionTimeout).toBe(5000);
|
||||
expect(settings?.requestTimeout).toBe(9000);
|
||||
expect(settings?.oauthClientId).toBe("client-abc");
|
||||
expect(settings?.oauthClientSecret).toBe("shh");
|
||||
expect(settings?.oauthScopes).toEqual(["a", "b"]);
|
||||
});
|
||||
|
||||
it("merges --header over disk headers while preserving disk timeouts", () => {
|
||||
const configPath = join(tempDir, "mcp.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
web: {
|
||||
type: "streamable-http",
|
||||
url: "http://x/mcp",
|
||||
headers: { Authorization: "Bearer disk" },
|
||||
requestTimeout: 9000,
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const servers = loadServerEntries({
|
||||
configPath,
|
||||
headers: { Authorization: "Bearer cli" },
|
||||
});
|
||||
expect(servers.web?.settings?.headers).toEqual([
|
||||
{ key: "Authorization", value: "Bearer cli" },
|
||||
]);
|
||||
// Disk timeout survives the header override.
|
||||
expect(servers.web?.settings?.requestTimeout).toBe(9000);
|
||||
});
|
||||
|
||||
it("merges --header into a server that has no disk settings", () => {
|
||||
const configPath = join(tempDir, "mcp.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
web: { type: "streamable-http", url: "http://x/mcp" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const servers = loadServerEntries({
|
||||
configPath,
|
||||
headers: { Authorization: "Bearer cli" },
|
||||
});
|
||||
expect(servers.web?.settings?.headers).toEqual([
|
||||
{ key: "Authorization", value: "Bearer cli" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies env and cwd overrides to stdio configs only", () => {
|
||||
const configPath = join(tempDir, "mcp.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify({
|
||||
mcpServers: {
|
||||
local: { command: "node", args: ["server.js"], env: { A: "1" } },
|
||||
web: { type: "streamable-http", url: "http://x/mcp" },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const servers = loadServerEntries({
|
||||
configPath,
|
||||
env: { B: "2" },
|
||||
cwd: "/tmp/work",
|
||||
});
|
||||
expect(servers.local?.config).toMatchObject({
|
||||
type: "stdio",
|
||||
command: "node",
|
||||
env: { A: "1", B: "2" },
|
||||
cwd: "/tmp/work",
|
||||
});
|
||||
// Non-stdio config is left untouched by env/cwd overrides.
|
||||
expect(servers.web?.config).toMatchObject({
|
||||
type: "streamable-http",
|
||||
url: "http://x/mcp",
|
||||
});
|
||||
});
|
||||
|
||||
it("seeds an empty writable catalog when --catalog is missing", () => {
|
||||
const catalogPath = join(tempDir, "catalog.json");
|
||||
const servers = loadServerEntries({ catalogPath });
|
||||
expect(servers).toEqual({});
|
||||
expect(existsSync(catalogPath)).toBe(true);
|
||||
expect(JSON.parse(readFileSync(catalogPath, "utf-8"))).toEqual({
|
||||
mcpServers: {},
|
||||
});
|
||||
});
|
||||
|
||||
it("throws when a read-only --config file is missing (never seeds)", () => {
|
||||
const configPath = join(tempDir, "absent.json");
|
||||
expect(() => loadServerEntries({ configPath })).toThrow(
|
||||
/Config file not found/,
|
||||
);
|
||||
expect(existsSync(configPath)).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects --catalog and --config together", () => {
|
||||
const catalogPath = join(tempDir, "catalog.json");
|
||||
const configPath = join(tempDir, "config.json");
|
||||
writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} }));
|
||||
writeFileSync(configPath, JSON.stringify({ mcpServers: {} }));
|
||||
expect(() => loadServerEntries({ catalogPath, configPath })).toThrow(
|
||||
/mutually exclusive/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects --catalog combined with an ad-hoc target", () => {
|
||||
const catalogPath = join(tempDir, "catalog.json");
|
||||
writeFileSync(catalogPath, JSON.stringify({ mcpServers: {} }));
|
||||
expect(() =>
|
||||
loadServerEntries({ catalogPath, target: ["my-server"] }),
|
||||
).toThrow(/--catalog cannot be combined/);
|
||||
});
|
||||
|
||||
it("builds a single ad-hoc server from a positional target", () => {
|
||||
const servers = loadServerEntries({
|
||||
target: ["my-server", "--flag"],
|
||||
headers: { Authorization: "Bearer t" },
|
||||
});
|
||||
expect(Object.keys(servers)).toEqual(["default"]);
|
||||
expect(servers.default?.config).toMatchObject({
|
||||
type: "stdio",
|
||||
command: "my-server",
|
||||
args: ["--flag"],
|
||||
});
|
||||
expect(servers.default?.settings?.headers).toEqual([
|
||||
{ key: "Authorization", value: "Bearer t" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("selectServerEntry", () => {
|
||||
const a: ResolvedServer = {
|
||||
config: { type: "stdio", command: "a" },
|
||||
};
|
||||
const b: ResolvedServer = {
|
||||
config: { type: "stdio", command: "b" },
|
||||
};
|
||||
|
||||
it("returns the named entry when it exists", () => {
|
||||
expect(selectServerEntry({ a, b }, "b")).toBe(b);
|
||||
});
|
||||
|
||||
it("throws listing available servers when the name is unknown", () => {
|
||||
expect(() => selectServerEntry({ a, b }, "missing")).toThrow(
|
||||
/Server 'missing' not found.*Available servers: a, b/,
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the only entry when no name is given", () => {
|
||||
expect(selectServerEntry({ a })).toBe(a);
|
||||
});
|
||||
|
||||
it("throws when the source is empty and no name is given", () => {
|
||||
expect(() => selectServerEntry({})).toThrow(/No servers found/);
|
||||
});
|
||||
|
||||
it("throws asking for --server when several exist and no name is given", () => {
|
||||
expect(() => selectServerEntry({ a, b })).toThrow(
|
||||
/Multiple servers found.*--server.*Available servers: a, b/,
|
||||
);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue