inspector/test-servers/src/load-config.ts
cliffhall a8cbc506ab fix: address Copilot review round 1 on #2306
Validate `oauth.clientMetadataPath` the way the other two metadata paths
are validated — in `loadConfig` for the JSON route and again at server
setup for a `ServerConfig` built in code. This path is not merely
advertised: it becomes the served document's own `client_id`, so an
off-origin or query-bearing value would publish a client id this server
cannot honour.

Derive that `client_id` from `req.originalUrl` rather than from the
registered route, so a document fetched with a query string answers with
a `client_id` byte-identical to the URL it was fetched from — the
equality CIMD turns on.

Correct the docs' fixed-port warning: this fixture configures no
`issuerUrl` and derives the `client_id` from the request, which is
exactly why the integration test can drive it on a harness-chosen port.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UWGH2ytPzSPxnp4cibaV3Z
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-10 22:49:36 -04:00

313 lines
11 KiB
TypeScript

/**
* Config loader for composable test server
* Reads JSON or YAML config files with format inferred from extension or --json/--yaml flag
*/
import { readFileSync } from "fs";
import path from "path";
import YAML from "yaml";
import { isOriginRelativePath } from "./test-server-oauth.js";
export interface PresetRef {
preset: string;
params?: Record<string, unknown>;
/** OAuth scopes the bearer token must include to use this capability. */
requiredScopes?: string[];
}
export interface ConfigFileOAuth {
enabled: boolean;
mode?: "combined" | "protected-resource";
authorizationServers?: string[];
resource?: string;
/** Serve RFC 9728 metadata from this path and advertise it on 401 (#2071). */
resourceMetadataPath?: string;
/** Serve RFC 8414 AS metadata from this path instead of the default (#2172). */
asMetadataPath?: string;
issuerUrl?: string;
accessTokenIssuers?: string[];
jwksUri?: string;
resourceAudience?: string;
scopesSupported?: string[];
requireAuth?: boolean;
staticClients?: Array<{
clientId: string;
clientSecret?: string;
redirectUris?: string[];
}>;
supportDCR?: boolean;
supportCIMD?: boolean;
/**
* Serve a CIMD client metadata document, making a CIMD fixture
* self-contained. `redirectUris` must list the Inspector callback for the
* web port under test. See the field's doc comment in
* `composable-test-server.ts`.
*/
clientMetadata?: {
redirectUris: string[];
clientName?: string;
scope?: string;
};
/**
* Where to serve `clientMetadata` (default `/client-metadata.json`);
* validated as an origin-relative path, since it becomes the document's own
* `client_id`. See `composable-test-server.ts`.
*/
clientMetadataPath?: string;
tokenExpirationSeconds?: number;
supportRefreshTokens?: boolean;
/** RFC 7009 revocation endpoint; default true (#2144). */
supportRevocation?: boolean;
}
export interface ConfigFile {
serverInfo: {
name: string;
version: string;
};
tools?: Array<PresetRef | PresetRef[]>;
resources?: PresetRef[];
resourceTemplates?: PresetRef[];
prompts?: PresetRef[];
logging?: boolean;
listChanged?: {
tools?: boolean;
resources?: boolean;
prompts?: boolean;
};
subscriptions?: boolean;
tasks?: {
list?: boolean;
cancel?: boolean;
};
/** Advertise the modern (SEP-2663) `io.modelcontextprotocol/tasks` extension
* and wire its handlers + `modern_task` / `modern_input_task` tools. Pair with
* `transport.modern`. */
tasksExtension?: boolean;
/** Advertise the Skills extension (SEP-2640) and serve its fixture skills,
* including `directoryRead` — see {@link ServerConfig.skills}. */
skills?: boolean;
/** Advertise the MCP Apps `io.modelcontextprotocol/ui` extension with the nested
* `elicitation` setting — the server half of app-rendered form elicitation
* (#1854). Pair with the `app_choose_option` tool + `choose_option_app` resource. */
appElicitation?: boolean;
maxPageSize?: {
tools?: number;
resources?: number;
resourceTemplates?: number;
prompts?: number;
};
/**
* Hand out `""` as the cursor for page two of every paginated list, instead
* of the usual numeric index. See {@link ServerConfig.emptyStringCursor}
* (#2220).
*/
emptyStringCursor?: boolean;
/**
* Names of registered tools to emit **twice** in `tools/list` (same `name`,
* the second's title marked "(duplicate)") — the nonconforming-but-real shape
* no preset can produce. See {@link ServerConfig.duplicateToolNames} (#1957).
*/
duplicateToolNames?: string[];
/**
* URIs to emit **twice** in `resources/list` (same `uri`, the second's title
* marked "(duplicate)") — matched against the assembled list, so a
* template-listed URI counts as well as a statically-registered one. See
* {@link ServerConfig.duplicateResourceUris} (#2206).
*/
duplicateResourceUris?: string[];
/**
* Replace a registered tool's advertised `inputSchema`/`outputSchema` with a
* raw JSON Schema document — the constructs a Zod-built preset cannot emit.
* See {@link ServerConfig.rawToolSchemas} (#1005).
*/
rawToolSchemas?: Record<
string,
{ inputSchema?: unknown; outputSchema?: unknown }
>;
/**
* Gate a tool's `tools/list` visibility on a client-declared extension. Maps
* extension id → tool name; the tool appears only when the connected client
* advertised that extension (#1739 / #1633). Legacy stateful leg only. See
* {@link ServerConfig.extensionGatedTools}.
*/
extensionGatedTools?: Record<string, string>;
oauth?: ConfigFileOAuth;
transport: {
type: "stdio" | "streamable-http" | "sse";
port?: number;
/**
* Serve the modern (2026-07-28) protocol era via the SDK's
* `createMcpHandler` (only valid with `type: "streamable-http"`). `true`
* is shorthand for dual-era stateless serving; the object form selects the
* legacy-fallback posture. See {@link ServerConfig.modern}.
*/
modern?:
| boolean
| {
legacy?: "stateless" | "reject";
injectSpecErrors?: boolean;
neverAcknowledgeSubscriptions?: boolean | "after-first";
};
};
}
export type ConfigFormat = "json" | "yaml";
function inferFormatFromPath(filePath: string): ConfigFormat | null {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".json") return "json";
if (ext === ".yaml" || ext === ".yml") return "yaml";
return null;
}
function parseContent(
content: string,
format: ConfigFormat,
filePath: string,
): unknown {
try {
if (format === "json") {
return JSON.parse(content);
}
return YAML.parse(content);
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
throw new Error(`Failed to parse config file ${filePath}: ${msg}`, {
cause: err,
});
}
}
function validateConfig(
obj: unknown,
filePath: string,
): asserts obj is ConfigFile {
if (obj === null || typeof obj !== "object") {
throw new Error(`Invalid config in ${filePath}: expected object`);
}
const o = obj as Record<string, unknown>;
if (
!o.serverInfo ||
typeof o.serverInfo !== "object" ||
typeof (o.serverInfo as Record<string, unknown>).name !== "string" ||
typeof (o.serverInfo as Record<string, unknown>).version !== "string"
) {
throw new Error(
`Invalid config in ${filePath}: serverInfo.name and serverInfo.version are required`,
);
}
if (
!o.transport ||
typeof o.transport !== "object" ||
typeof (o.transport as Record<string, unknown>).type !== "string"
) {
throw new Error(
`Invalid config in ${filePath}: transport.type is required`,
);
}
const transport = o.transport as Record<string, unknown>;
const transportType = transport.type as string;
if (!["stdio", "streamable-http", "sse"].includes(transportType)) {
throw new Error(
`Invalid config in ${filePath}: transport.type must be stdio, streamable-http, or sse`,
);
}
// Only reject *enabling* modern on a non-HTTP transport; a falsy `modern`
// (e.g. `false`) is a no-op that `resolveConfig` normalizes away.
if (transport.modern && transportType !== "streamable-http") {
throw new Error(
`Invalid config in ${filePath}: transport.modern requires transport.type "streamable-http"`,
);
}
if (o.oauth && typeof o.oauth === "object") {
const oauth = o.oauth as Record<string, unknown>;
if (oauth.enabled !== true) {
throw new Error(
`Invalid config in ${filePath}: oauth.enabled must be true when oauth is present`,
);
}
const mode = oauth.mode;
if (
mode !== undefined &&
mode !== "combined" &&
mode !== "protected-resource"
) {
throw new Error(
`Invalid config in ${filePath}: oauth.mode must be combined or protected-resource`,
);
}
if (mode === "protected-resource") {
const servers = oauth.authorizationServers;
if (!Array.isArray(servers) || servers.length === 0) {
throw new Error(
`Invalid config in ${filePath}: oauth.authorizationServers is required when oauth.mode is protected-resource`,
);
}
for (const url of servers) {
if (typeof url !== "string" || url.trim() === "") {
throw new Error(
`Invalid config in ${filePath}: oauth.authorizationServers must be non-empty URL strings`,
);
}
}
}
const metadataPath = oauth.resourceMetadataPath;
if (metadataPath !== undefined && !isOriginRelativePath(metadataPath)) {
throw new Error(
`Invalid config in ${filePath}: oauth.resourceMetadataPath must be an origin-relative path (e.g. "/custom/protected-resource") — a value such as "//host/doc" would advertise a document the server does not serve`,
);
}
const asPath = oauth.asMetadataPath;
if (asPath !== undefined && !isOriginRelativePath(asPath)) {
throw new Error(
`Invalid config in ${filePath}: oauth.asMetadataPath must be an origin-relative path (e.g. "/.well-known/openid-configuration") — a value such as "//host/doc" would move the document off this server entirely`,
);
}
const cimdPath = oauth.clientMetadataPath;
if (cimdPath !== undefined && !isOriginRelativePath(cimdPath)) {
throw new Error(
`Invalid config in ${filePath}: oauth.clientMetadataPath must be an origin-relative path (e.g. "/client-metadata.json") — this path becomes the document's own client_id, so a value such as "//host/doc" would publish a client id naming a host this server does not serve`,
);
}
if (transportType === "stdio" && oauth.enabled === true) {
throw new Error(
`Invalid config in ${filePath}: oauth requires streamable-http or sse transport`,
);
}
}
}
/**
* Load config from file. Format is inferred from extension unless overridden by format option.
* Paths in config are resolved relative to cwd.
*/
export function loadConfig(
filePath: string,
options?: { format?: ConfigFormat },
): ConfigFile {
const explicitFormat = options?.format;
const inferredFormat = inferFormatFromPath(filePath);
let format: ConfigFormat;
if (explicitFormat) {
format = explicitFormat;
} else if (inferredFormat) {
format = inferredFormat;
} else {
throw new Error(
`Cannot infer config format from path ${filePath}. ` +
`Use .json, .yaml, or .yml extension, or pass --json or --yaml flag`,
);
}
const resolvedPath = path.isAbsolute(filePath)
? filePath
: path.resolve(process.cwd(), filePath);
const content = readFileSync(resolvedPath, "utf-8");
const parsed = parseContent(content, format, resolvedPath);
validateConfig(parsed, resolvedPath);
return parsed as ConfigFile;
}