connectionTimeout defaulted to 0, which InspectorClient.connect() read as "no bound at all" — so out of the box nothing the Inspector owns bounded a connect attempt, and the only thing ending a hung handshake was the SDK's 60 s per-request timeout on initialize, reporting "Request timed out". - Add DEFAULT_CONNECTION_TIMEOUT_MS (30000) to core/mcp/types.ts. An absent value resolves to it on the client, on disk read, in the web form draft, and in the CLI's header-only settings shell. An explicit 0 keeps its documented meaning (no timeout; --connect-timeout 0) and is now persisted so the opt-out round-trips; the default is the omit-sentinel, as for taskTtl. - Key the post-timeout transport teardown to the timer actually firing, not to connectionTimeout > 0 — otherwise a non-zero default would tear down every failed connect, including a recoverable 401 whose transport the auth recovery holds open. - The timeout message names the bound and where to raise it: "Connection timed out after N ms. To accommodate a slower server, you may increase the timeout value in Server Settings." - The CLI's UNREACHABLE classifier now matches that message. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M4h8Xa6Ed9zwtv5ojX35MT Signed-off-by: cliffhall <cliff@futurescale.com>
176 lines
6.2 KiB
TypeScript
176 lines
6.2 KiB
TypeScript
import type { SecretStore } from "../../auth/node/secret-store.js";
|
|
import { defaultSecretStore } from "../../auth/node/secret-store-selection.js";
|
|
import type { InspectorServerSettings, MCPServerConfig } from "../types.js";
|
|
import {
|
|
DEFAULT_CONNECTION_TIMEOUT_MS,
|
|
DEFAULT_MAX_FETCH_REQUESTS,
|
|
DEFAULT_TASK_TTL_MS,
|
|
} from "../types.js";
|
|
import { mcpConfigToServerEntries } from "../serverList.js";
|
|
import {
|
|
applyOverrides,
|
|
resolveServerConfigs,
|
|
resolveServerSource,
|
|
serverSourceConflict,
|
|
readServerListFile,
|
|
hasAdHocServerOptions,
|
|
withDefaultCatalogPath,
|
|
type ServerConfigOptions,
|
|
} from "./config.js";
|
|
import { rehydrateMcpConfigFromKeychain } from "./server-secrets.js";
|
|
|
|
/**
|
|
* A server resolved from a catalog/config file or an ad-hoc target, paired with
|
|
* the per-server settings (headers, timeouts, OAuth, etc.) lifted from the file.
|
|
* `config` is the SDK-facing transport config; `settings` is the v2
|
|
* `InspectorServerSettings` model (post-#1358 top-level shape).
|
|
*/
|
|
export type ResolvedServer = {
|
|
config: MCPServerConfig;
|
|
settings?: InspectorServerSettings;
|
|
};
|
|
|
|
/** Loader options: the shared source flags plus a single `--header` set that is
|
|
* broadcast into every resolved server's settings. */
|
|
export type ServerLoadOptions = ServerConfigOptions & {
|
|
headers?: Record<string, string>;
|
|
/** Test injection; defaults to the selected store (see
|
|
* `secret-store-selection.ts`) for catalog/config loads. */
|
|
secretStore?: SecretStore;
|
|
};
|
|
|
|
/**
|
|
* Build an `InspectorServerSettings` carrying only `--header` overrides (all
|
|
* other fields defaulted). Returns undefined when no headers are given so it can
|
|
* be used as a no-op base in `mergeSettings`.
|
|
*/
|
|
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 })),
|
|
env: [],
|
|
metadata: {},
|
|
connectionTimeout: DEFAULT_CONNECTION_TIMEOUT_MS,
|
|
requestTimeout: 0,
|
|
taskTtl: DEFAULT_TASK_TTL_MS,
|
|
maxFetchRequests: DEFAULT_MAX_FETCH_REQUESTS,
|
|
autoRefreshOnListChanged: false,
|
|
paginatedLists: false,
|
|
roots: [],
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Overlay CLI `--header` values onto the settings lifted from the file. Only the
|
|
* `headers` field is overridden — timeouts, OAuth, and the rest of the file's
|
|
* settings are preserved.
|
|
*/
|
|
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 };
|
|
}
|
|
|
|
/**
|
|
* Resolve every server from a catalog/config file (or a single ad-hoc target)
|
|
* into `{ config, settings }`, lifting disk-level headers/timeouts/OAuth into
|
|
* `InspectorServerSettings` via `mcpConfigToServerEntries`. Shared by the CLI
|
|
* and TUI so both apply the same file→settings resolution (issue #1482).
|
|
*
|
|
* Applies the default writable catalog when no source/ad-hoc target is given,
|
|
* enforces the `--catalog`/`--config`/ad-hoc conflict matrix, and seeds an empty
|
|
* writable catalog on first run (a missing read-only `--config` still errors).
|
|
*/
|
|
export async function loadServerEntries(
|
|
serverOptions: ServerLoadOptions,
|
|
): Promise<Record<string, ResolvedServer>> {
|
|
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) {
|
|
let config = readServerListFile(source.path, source.writable);
|
|
const secretStore = serverOptions.secretStore ?? defaultSecretStore();
|
|
config = await rehydrateMcpConfigFromKeychain(config, secretStore);
|
|
const entries = mcpConfigToServerEntries(config);
|
|
const result: Record<string, ResolvedServer> = {};
|
|
for (const entry of entries) {
|
|
result[entry.name] = {
|
|
config: applyOverrides(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),
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Pick a single resolved server for runners that connect to exactly one (the
|
|
* CLI). With `serverName`, returns that entry or errors listing the available
|
|
* names; otherwise requires exactly one server in the source.
|
|
*
|
|
* The unknown-name error is source-agnostic ("not found" rather than "not found
|
|
* in config file") because `entries` may come from a file *or* a single ad-hoc
|
|
* target — e.g. `--server foo` alongside a positional command resolves to just
|
|
* `{ default }`, where "in config file" would be misleading.
|
|
*/
|
|
export function selectServerEntry(
|
|
entries: Record<string, ResolvedServer>,
|
|
serverName?: string,
|
|
): ResolvedServer {
|
|
const names = Object.keys(entries);
|
|
if (serverName) {
|
|
const entry = entries[serverName];
|
|
if (!entry) {
|
|
throw new Error(
|
|
`Server '${serverName}' not found. Available servers: ${names.join(", ")}`,
|
|
);
|
|
}
|
|
return entry;
|
|
}
|
|
if (names.length === 0) {
|
|
throw new Error("No servers found in config file");
|
|
}
|
|
if (names.length > 1) {
|
|
throw new Error(
|
|
`Multiple servers found in config file. Please specify one with --server. Available servers: ${names.join(", ")}`,
|
|
);
|
|
}
|
|
return entries[names[0]!]!;
|
|
}
|