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

View file

@ -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();