* Add modern-era (2026-07-28) test-server preset (#1700) The era-aware connection model (#1626, PR #1697) added per-server `protocolEra` and the era-aware Connection Info UI, but no bundled test server negotiated the modern era — the modern happy path had only unit/Storybook fixtures, no live coverage. Serve the modern (2026-07-28) protocol era from the HTTP test server via the SDK's `createMcpHandler`. A `modern` flag on `ServerConfig` (and a `transport.modern` field in the JSON config) mounts the handler with a dual-era `legacy: "stateless"` fallback by default, or `legacy: "reject"` for modern-only strict serving. The web-standard `fetch` handler reuses the existing `toWebRequest`/`writeWebResponse` conversion, so no new `@modelcontextprotocol/node` dependency is needed. An SDK v2 client negotiating `protocolEra: "auto" | "modern"` now reaches the modern leg end-to-end: `getProtocolEra() === "modern"`, `getNegotiatedProtocolVersion() === "2026-07-28"`, and a populated `getDiscoverResult()`. Verified live in the Inspector — Connection Info shows Era Modern / Session Sessionless / the Discovery section. - Modern serving path + `listen`/`startModernHttp` helpers in test-server-http.ts; modern-leg teardown in stop(). - `modern` config field threaded through load-config/resolve-config with a streamable-http-only validation guard; modern-http.json demo config. - Integration test covering auto/modern negotiation, the dual-era legacy fallback, a modern tools/call, and the strict `legacy: "reject"` rejection. Closes #1700 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 * Review nit: gate transport.modern guard on truthiness A falsy `transport.modern` (e.g. `false`) is a no-op that `resolveConfig` normalizes to `undefined`, so it shouldn't trip the streamable-http-only guard. Gate on truthiness instead of `!== undefined` so only *enabling* modern on a non-HTTP transport is rejected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XNdjEPKLG637X8YmhDiEk5 --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
115 lines
3.6 KiB
TypeScript
115 lines
3.6 KiB
TypeScript
/**
|
|
* Resolves config file preset refs to ServerConfig for createMcpServer
|
|
*/
|
|
|
|
import type { ServerConfig } from "./composable-test-server.js";
|
|
import type {
|
|
ToolDefinition,
|
|
TaskToolDefinition,
|
|
ResourceDefinition,
|
|
PromptDefinition,
|
|
ResourceTemplateDefinition,
|
|
} from "./composable-test-server.js";
|
|
import { createTestServerInfo } from "./test-server-fixtures.js";
|
|
import { resolvePreset } from "./preset-registry.js";
|
|
import type { ConfigFile, PresetRef } from "./load-config.js";
|
|
|
|
function mergeRequiredScopes<T extends { requiredScopes?: string[] }>(
|
|
item: T,
|
|
ref: PresetRef,
|
|
): T {
|
|
if (!ref.requiredScopes?.length) {
|
|
return item;
|
|
}
|
|
return { ...item, requiredScopes: ref.requiredScopes };
|
|
}
|
|
|
|
function resolvePresetRefs<T extends { requiredScopes?: string[] }>(
|
|
refs: Array<PresetRef | PresetRef[]> | undefined,
|
|
type: "tool" | "resource" | "resourceTemplate" | "prompt",
|
|
): T[] {
|
|
if (!refs || refs.length === 0) return [];
|
|
const result: Array<{ requiredScopes?: string[] }> = [];
|
|
for (const entry of refs) {
|
|
const items = Array.isArray(entry) ? entry : [entry];
|
|
for (const ref of items) {
|
|
const presetName = ref.preset;
|
|
if (!presetName || typeof presetName !== "string") {
|
|
throw new Error(
|
|
`Invalid preset ref: preset must be a non-empty string`,
|
|
);
|
|
}
|
|
const resolved = resolvePreset(type, presetName, ref.params);
|
|
const arr = Array.isArray(resolved) ? resolved : [resolved];
|
|
for (const item of arr) {
|
|
result.push(mergeRequiredScopes(item, ref));
|
|
}
|
|
}
|
|
}
|
|
// The caller pairs `type` with the matching `T`, so each resolved preset is
|
|
// the requested definition kind; TS can't see that correspondence.
|
|
return result as T[];
|
|
}
|
|
|
|
/**
|
|
* Resolve config file to ServerConfig for createMcpServer
|
|
*/
|
|
export function resolveConfig(config: ConfigFile): ServerConfig {
|
|
const tools = resolvePresetRefs<ToolDefinition | TaskToolDefinition>(
|
|
config.tools,
|
|
"tool",
|
|
);
|
|
const resources = resolvePresetRefs<ResourceDefinition>(
|
|
config.resources,
|
|
"resource",
|
|
);
|
|
const resourceTemplates = resolvePresetRefs<ResourceTemplateDefinition>(
|
|
config.resourceTemplates,
|
|
"resourceTemplate",
|
|
);
|
|
const prompts = resolvePresetRefs<PromptDefinition>(config.prompts, "prompt");
|
|
|
|
const serverInfo = createTestServerInfo(
|
|
config.serverInfo.name,
|
|
config.serverInfo.version,
|
|
);
|
|
|
|
const transport = config.transport;
|
|
const isHttp =
|
|
transport.type === "streamable-http" || transport.type === "sse";
|
|
|
|
const serverConfig: ServerConfig = {
|
|
serverInfo,
|
|
tools: tools.length > 0 ? tools : undefined,
|
|
resources: resources.length > 0 ? resources : undefined,
|
|
resourceTemplates:
|
|
resourceTemplates.length > 0 ? resourceTemplates : undefined,
|
|
prompts: prompts.length > 0 ? prompts : undefined,
|
|
logging: config.logging,
|
|
listChanged: config.listChanged,
|
|
subscriptions: config.subscriptions,
|
|
tasks: config.tasks,
|
|
maxPageSize: config.maxPageSize,
|
|
serverType: isHttp
|
|
? (transport.type as "sse" | "streamable-http")
|
|
: undefined,
|
|
port: isHttp ? transport.port : undefined,
|
|
};
|
|
|
|
// Normalize the modern flag: `true` is shorthand for the default (dual-era
|
|
// stateless) posture; the object form passes through.
|
|
if (transport.modern !== undefined) {
|
|
serverConfig.modern =
|
|
transport.modern === true ? {} : transport.modern || undefined;
|
|
}
|
|
|
|
if (config.oauth) {
|
|
const { issuerUrl, ...oauthRest } = config.oauth;
|
|
serverConfig.oauth = {
|
|
...oauthRest,
|
|
...(issuerUrl && { issuerUrl: new URL(issuerUrl) }),
|
|
};
|
|
}
|
|
|
|
return serverConfig;
|
|
}
|