inspector/test-servers/src/resolve-config.ts
cliffhall 9b35e530fc fix: send an empty-string pagination cursor verbatim on every list adapter (#2220)
An MCP cursor is an opaque string: the spec constrains neither its content
nor its length, so `""` is a `nextCursor` a server may legitimately return
and the client is required to send back verbatim.

`listPrompts`, `listResources`, `listResourceTemplates` and
`listRequestorTasks` built their request params with `cursor ? { cursor } : {}`,
a truthiness check that cannot distinguish "no cursor" from "the cursor is
the empty string". Against a server that paginates with `""` they dropped it
and re-requested page one — a list that silently stops at the first page, or
a walk that loops on it forever. Nothing surfaced an error, because the
request was well-formed; it just asked the wrong question. `listTools` (and
the later `listSkills`) already used `cursor !== undefined`; this brings the
other four into line.

`getReplayableParams` in `protocolReplay.ts` existed partly to mirror the
asymmetry — its `method === "tools/list" || params.cursor !== ""` special
case collapses to `typeof params.cursor === "string"` now that the adapters
agree, and its long comment is updated rather than left describing a state
that no longer exists.

Coverage:

- `inspectorClient-list-cursor.test.ts` asserts the outbound params of all
  five adapters — `""` carried, an absent cursor sending no `cursor` key,
  a non-empty cursor forwarded unchanged. `listTools` is the control.
- `test-servers/configs/empty-cursor-http.json` (`emptyStringCursor`) hands
  out `""` as the cursor for page two, the shape no other fixture produces,
  and `empty-cursor.test.ts` walks all three of its lists end to end.
- Removing the fix from any one adapter fails exactly its two cases.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EN2UfrkQZvtrviMtqURRm4
Signed-off-by: cliffhall <cliff@futurescale.com>
2026-09-06 13:28:44 -04:00

122 lines
3.9 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,
tasksExtension: config.tasksExtension,
skills: config.skills,
appElicitation: config.appElicitation,
maxPageSize: config.maxPageSize,
emptyStringCursor: config.emptyStringCursor,
duplicateToolNames: config.duplicateToolNames,
rawToolSchemas: config.rawToolSchemas,
extensionGatedTools: config.extensionGatedTools,
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;
}