feat(cli): lift config-file headers/timeouts/OAuth into serverSettings (#1482) (#1500)

* 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:
Cliff Hall 2026-06-20 11:33:21 -04:00 • committed by GitHub
commit e72d4307f3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 1033 additions and 420 deletions

View file

@ -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 {

View 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/,
);
});
});