inspector/test-servers/src/preset-registry.ts
cliffhall 937f1dd51b fix: render nullable enum fields as a Select instead of a raw JSON textarea
A tool argument that is optional AND explicitly nullable — Zod's
`.nullish()` / `.nullable().optional()` — compiles to
`anyOf: [{ type: "string", enum: [...] }, { type: "null" }]`. Every branch
of the tool-call form's field dispatcher tests a single `type` string, so
such a field matched nothing and fell through to the raw-JSON fallback,
whose onChange stores the unparseable in-progress text back into state and
re-`JSON.stringify`s it on the next render — compounding a fresh layer of
escaping on every keystroke.

`normalizeUnionType` already existed for the FastMCP `string|null` shape,
but it only copied the surviving branch's `type` and dropped everything
else, so the `enum` never reached the dispatcher; and nothing called it on
a tool input schema — only elicitation requests, via
`resolveRefsInMessage`. So:

- Rewrite `normalizeUnionType` to hoist the non-null branch's own keywords
  (`enum`, `items`, `properties`, `minimum`, …) onto the flattened schema,
  as v1.x did. This also collapses nine near-identical blocks into two and
  picks up the `object`/`array` cases the `type: [X, "null"]` form was
  missing.
- Normalize each field in `SchemaForm.renderField`, so tool, elicitation,
  and app forms all get it rather than just elicitation.
- Give a nullable enum `Select` a clear affordance, so a value can be set
  back to `null`; a non-nullable enum keeps none, since it would reject it.

Adds a `record_shipment` preset and `nullable-fields-http.json` showcase
config serving all four nullable scalar shapes, for manual verification.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-15 16:10:17 -04:00

306 lines
8.9 KiB
TypeScript

/**
* Preset registry for config-driven composable server
* Maps preset names to fixture factory functions
*/
import type {
ToolDefinition,
TaskToolDefinition,
ResourceDefinition,
PromptDefinition,
ResourceTemplateDefinition,
} from "./composable-test-server.js";
import {
createEchoTool,
createNullableFieldsTool,
createGetEnvTool,
createAddTool,
createGetSumTool,
createGetWeatherTool,
createInvalidHeaderTool,
createSpecErrorTriggerTool,
createWriteToStderrTool,
createCollectSampleTool,
createListRootsTool,
createCollectFormElicitationTool,
createMrtrTool,
createMrtrMultiRoundTool,
createMrtrRootsTool,
createMrtrSamplingTool,
createMrtrLoopTool,
createMrtrEdgeCaseTool,
createCollectUrlElicitationTool,
createUrlElicitationFormTool,
createSendNotificationTool,
createGetAnnotatedMessageTool,
createGetTempTool,
createGetTempExtraTool,
createListItemsTool,
createAddResourceTool,
createRemoveResourceTool,
createAddToolTool,
createRemoveToolTool,
createAddPromptTool,
createRemovePromptTool,
createUpdateResourceTool,
createSendProgressTool,
createNumberedTools,
createSimpleTaskTool,
createProgressTaskTool,
createElicitationTaskTool,
createSamplingTaskTool,
createOptionalTaskTool,
createForbiddenTaskTool,
createImmediateReturnTaskTool,
createMcpAppDemoTool,
createMcpAppDemoResource,
createArchitectureResource,
createTestCwdResource,
createTestEnvResource,
createTestArgvResource,
createNumberedResources,
createFileResourceTemplate,
createUserResourceTemplate,
createNumberedResourceTemplates,
createSimplePrompt,
createArgsPrompt,
createNumberedPrompts,
} from "./test-server-fixtures.js";
export type PresetType = "tool" | "resource" | "resourceTemplate" | "prompt";
export type PresetResult =
| ToolDefinition
| TaskToolDefinition
| ResourceDefinition
| PromptDefinition
| ResourceTemplateDefinition
| (ToolDefinition | TaskToolDefinition)[]
| ResourceDefinition[]
| ResourceTemplateDefinition[]
| PromptDefinition[];
function resolveToolPreset(
name: string,
params?: Record<string, unknown>,
):
| ToolDefinition
| TaskToolDefinition
| (ToolDefinition | TaskToolDefinition)[] {
const p = params ?? {};
const get = (k: string) => p[k] as unknown;
switch (name) {
case "echo":
return createEchoTool();
case "record_shipment":
case "nullable_fields":
return createNullableFieldsTool();
case "get-env":
case "get_env":
return createGetEnvTool();
case "add":
return createAddTool();
case "get_sum":
return createGetSumTool();
case "get_weather":
return createGetWeatherTool();
case "invalid_header_tool":
return createInvalidHeaderTool();
case "trigger_header_mismatch":
return createSpecErrorTriggerTool(
"trigger_header_mismatch",
"Returns -32020 HeaderMismatch (HTTP 400)",
);
case "trigger_missing_capability":
return createSpecErrorTriggerTool(
"trigger_missing_capability",
"Returns -32021 MissingRequiredClientCapability (HTTP 400)",
);
case "trigger_unsupported_version":
return createSpecErrorTriggerTool(
"trigger_unsupported_version",
"Returns -32022 UnsupportedProtocolVersion (HTTP 400)",
);
case "trigger_method_not_found":
return createSpecErrorTriggerTool(
"trigger_method_not_found",
"Returns -32601 MethodNotFound (HTTP 404)",
);
case "trigger_invalid_params":
return createSpecErrorTriggerTool(
"trigger_invalid_params",
"Returns a generic -32602 Invalid params (not an unknown tool)",
);
case "write_to_stderr":
return createWriteToStderrTool();
case "collect_sample":
return createCollectSampleTool();
case "list_roots":
return createListRootsTool();
case "collect_elicitation":
return createCollectFormElicitationTool();
case "mrtr_confirm":
return createMrtrTool();
case "mrtr_two_step":
return createMrtrMultiRoundTool();
case "mrtr_roots":
return createMrtrRootsTool();
case "mrtr_sample":
return createMrtrSamplingTool();
case "mrtr_loop":
return createMrtrLoopTool();
case "mrtr_edge":
return createMrtrEdgeCaseTool();
case "collect_url_elicitation":
return createCollectUrlElicitationTool();
case "url_elicitation_form":
return createUrlElicitationFormTool();
case "send_notification":
return createSendNotificationTool();
case "get_annotated_message":
return createGetAnnotatedMessageTool();
case "get_temp":
return createGetTempTool();
case "get_temp_extra":
return createGetTempExtraTool();
case "list_items":
return createListItemsTool();
case "add_resource":
return createAddResourceTool();
case "remove_resource":
return createRemoveResourceTool();
case "add_tool":
return createAddToolTool();
case "remove_tool":
return createRemoveToolTool();
case "add_prompt":
return createAddPromptTool();
case "remove_prompt":
return createRemovePromptTool();
case "update_resource":
return createUpdateResourceTool();
case "send_progress":
return createSendProgressTool(get("name") as string | undefined);
case "numbered_tools":
return createNumberedTools(Number(get("count")) || 5);
case "simple_task":
return createSimpleTaskTool(
get("name") as string | undefined,
Number(get("delayMs")) || undefined,
);
case "progress_task":
return createProgressTaskTool(
get("name") as string | undefined,
Number(get("delayMs")) || undefined,
Number(get("progressUnits")) || undefined,
);
case "elicitation_task":
return createElicitationTaskTool(get("name") as string | undefined);
case "sampling_task":
return createSamplingTaskTool(
get("name") as string | undefined,
get("samplingText") as string | undefined,
);
case "optional_task":
return createOptionalTaskTool(
get("name") as string | undefined,
Number(get("delayMs")) || undefined,
);
case "forbidden_task":
return createForbiddenTaskTool(
get("name") as string | undefined,
Number(get("delayMs")) || undefined,
);
case "immediate_return_task":
return createImmediateReturnTaskTool(
get("name") as string | undefined,
Number(get("delayMs")) || undefined,
);
case "mcp_app_demo":
return createMcpAppDemoTool();
default:
throw new Error(`Unknown tool preset: ${name}`);
}
}
function resolveResourcePreset(
name: string,
params?: Record<string, unknown>,
): ResourceDefinition | ResourceDefinition[] {
const p = params ?? {};
const get = (k: string) => p[k] as unknown;
switch (name) {
case "architecture":
return createArchitectureResource();
case "test_cwd":
return createTestCwdResource();
case "test_env":
return createTestEnvResource();
case "test_argv":
return createTestArgvResource();
case "numbered_resources":
return createNumberedResources(Number(get("count")) || 3);
case "mcp_app_demo_widget":
return createMcpAppDemoResource();
default:
throw new Error(`Unknown resource preset: ${name}`);
}
}
function resolveResourceTemplatePreset(
name: string,
params?: Record<string, unknown>,
): ResourceTemplateDefinition | ResourceTemplateDefinition[] {
const p = params ?? {};
const get = (k: string) => p[k] as unknown;
switch (name) {
case "file":
return createFileResourceTemplate();
case "user":
return createUserResourceTemplate();
case "numbered_resource_templates":
return createNumberedResourceTemplates(Number(get("count")) || 3);
default:
throw new Error(`Unknown resource template preset: ${name}`);
}
}
function resolvePromptPreset(
name: string,
params?: Record<string, unknown>,
): PromptDefinition | PromptDefinition[] {
const p = params ?? {};
const get = (k: string) => p[k] as unknown;
switch (name) {
case "simple_prompt":
return createSimplePrompt();
case "args_prompt":
return createArgsPrompt();
case "numbered_prompts":
return createNumberedPrompts(Number(get("count")) || 3);
default:
throw new Error(`Unknown prompt preset: ${name}`);
}
}
/**
* Resolve a preset by type and name to definition(s)
*/
export function resolvePreset(
type: PresetType,
name: string,
params?: Record<string, unknown>,
): PresetResult {
switch (type) {
case "tool":
return resolveToolPreset(name, params);
case "resource":
return resolveResourcePreset(name, params);
case "resourceTemplate":
return resolveResourceTemplatePreset(name, params);
case "prompt":
return resolvePromptPreset(name, params);
default:
throw new Error(`Unknown preset type: ${type}`);
}
}