inspector/test-servers/src/test-server-fixtures.ts
cliffhall 1c24eda429 fix: validate the whole varspec, and derive the TUI's message from its gate
Addresses Copilot's round-7 review on #2035. Two suppressed comments, both
real; each reproduced against this branch before acting.

1. `parseVarSpec` was a "strip the first `*`, trim, split on `:`" pass, so
   it accepted shapes RFC 6570's varspec production does not admit --
   measured on the previous commit:

     x://{*id}     name "id"  =>  "x://abcdef"   explode is a TRAILING modifier
     x://{id*:3}   name "id"  =>  "x://abc"      the two modifiers are exclusive
     x://{ id }    name "id"  =>  "x://abcdef"   whitespace is not a varchar
     x://{a b}     name "a b" =>  "x://1"        ditto -- and it emits into the URI

   `{id*:3}` is the worst of them: it silently truncated the value through a
   combination the grammar forbids. Parsing is now one anchored regex --
   a varname, then at most one trailing `*` or `:1-9999`.

   The name set is `varchar` widened by `-` and `~`, deliberately. A name is
   the one thing expansion emits into the URI WITHOUT encoding it (`;`, `?`
   and `&` write `name=value`), so the rule it has to satisfy is "needs no
   encoding" -- RFC 3986 unreserved plus pct-encoded, of which the RFC's own
   varchar is a subset. Strict varchar would refuse `{user-id}`, which real
   servers publish and which the SDK's matcher round-trips; refusing to read
   a resource that demonstrably works is a worse outcome for a debugging
   tool than a name one character outside the production. Everything now
   rejected is a shape we would otherwise have to guess at.

2. The TUI built its "Missing required template variable(s): ..." list with
   its own bare `values[name]` filter while gating on `hasRequiredValues`,
   which reads own properties only. For a variable legitimately named
   `constructor` the two disagreed: `({})["constructor"]` is `Object`, whose
   `.length` is 1, so the list came out empty and the error named no field
   at all while the submit stayed blocked. Both now come from one exported
   `unmetRequiredGroups`, with `hasRequiredValues` defined in terms of it.

Also renames a test whose title still said multi-name query expressions were
left "to the SDK, which handles them" -- expansion has been owned here since
935113d1; the behavior it asserts is naming, not delegation.

npm run ci passes.

Signed-off-by: cliffhall <cliff@futurescale.com>
2026-08-16 22:48:36 -04:00

2667 lines
84 KiB
TypeScript

/**
* Shared test fixtures for composable MCP test servers
*
* This module provides helper functions for creating test tools, prompts, and resources.
* For the core composable server types and createMcpServer function, see composable-test-server.ts
*/
import * as z from "zod/v4";
import {
RELATED_TASK_META_KEY,
inputRequired,
type Implementation,
type ElicitRequestFormParams,
type ElicitRequestURLParams,
type GetTaskResult,
type CallToolResult,
} from "@modelcontextprotocol/server";
import {
CreateMessageResultSchema,
CreateTaskResultSchema,
ElicitResultSchema,
GetTaskResultSchema,
} from "@modelcontextprotocol/core";
import type {
ToolDefinition,
TaskToolDefinition,
ResourceDefinition,
PromptDefinition,
ResourceTemplateDefinition,
ServerConfig,
TestServerContext,
TaskRequestHandlerExtra,
CreateTaskRequestHandlerExtra,
HandlerExtra,
ShapeOutput,
} from "./composable-test-server.js";
import { getTestServerControl } from "./test-server-control.js";
/** Build a CallToolResult from a text message (and optional isError). */
function toToolResult(text: string, isError?: boolean): CallToolResult {
return {
content: [{ type: "text", text }],
...(isError && { isError: true }),
};
}
// Re-export types and functions from composable-test-server for backward compatibility
export type {
ToolDefinition,
TaskToolDefinition,
ResourceDefinition,
PromptDefinition,
ResourceTemplateDefinition,
ServerConfig,
} from "./composable-test-server.js";
export { createMcpServer } from "./composable-test-server.js";
/**
* Create multiple numbered tools for pagination testing
* @param count Number of tools to create
* @returns Array of tool definitions
*/
export function createNumberedTools(count: number): ToolDefinition[] {
const tools: ToolDefinition[] = [];
for (let i = 1; i <= count; i++) {
tools.push({
name: `tool_${i}`,
description: `Test tool number ${i}`,
inputSchema: {
message: z.string().describe(`Message for tool ${i}`),
},
handler: async (params: Record<string, unknown>) => {
return toToolResult(`Tool ${i}: ${params.message as string}`);
},
});
}
return tools;
}
/**
* Create multiple numbered resources for pagination testing
* @param count Number of resources to create
* @returns Array of resource definitions
*/
export function createNumberedResources(count: number): ResourceDefinition[] {
const resources: ResourceDefinition[] = [];
for (let i = 1; i <= count; i++) {
resources.push({
name: `resource_${i}`,
uri: `test://resource_${i}`,
description: `Test resource number ${i}`,
mimeType: "text/plain",
text: `Content for resource ${i}`,
});
}
return resources;
}
/**
* Create multiple numbered resource templates for pagination testing
* @param count Number of resource templates to create
* @returns Array of resource template definitions
*/
export function createNumberedResourceTemplates(
count: number,
): ResourceTemplateDefinition[] {
const templates: ResourceTemplateDefinition[] = [];
for (let i = 1; i <= count; i++) {
templates.push({
name: `template_${i}`,
uriTemplate: `test://template_${i}/{param}`,
description: `Test resource template number ${i}`,
handler: async (uri: URL, variables: Record<string, unknown>) => {
return {
contents: [
{
uri: uri.toString(),
mimeType: "text/plain",
text: `Content for template ${i} with param ${variables.param}`,
},
],
};
},
});
}
return templates;
}
/**
* Create multiple numbered prompts for pagination testing
* @param count Number of prompts to create
* @returns Array of prompt definitions
*/
export function createNumberedPrompts(count: number): PromptDefinition[] {
const prompts: PromptDefinition[] = [];
for (let i = 1; i <= count; i++) {
prompts.push({
name: `prompt_${i}`,
description: `Test prompt number ${i}`,
promptString: `This is prompt ${i}`,
});
}
return prompts;
}
/**
* Create an "echo" tool that echoes back the input message
*/
export function createEchoTool(): ToolDefinition {
return {
name: "echo",
description: "Echo back the input message",
inputSchema: {
message: z.string().describe("Message to echo back"),
},
handler: async (
params: Record<string, unknown>,
_context?: TestServerContext,
) => {
return toToolResult(`Echo: ${params.message as string}`);
},
};
}
/**
* Create a "record_shipment" tool whose arguments are all **nullable** — the
* shape Zod's `.nullish()` / `.nullable()` compiles to, where the real type
* sits inside `anyOf: [<branch>, { type: "null" }]` rather than at the top
* level.
*
* `direction` is the case from #1928: a nullable *enum*, whose `enum` keyword
* lives on the surviving branch. Before the fix nothing in the tool-call form
* matched such a field, so it fell through to the raw-JSON textarea and
* re-escaped its own value on every keystroke. The other three cover the
* remaining scalar branches so a regression in one is visible next to a
* working sibling.
*/
export function createNullableFieldsTool(): ToolDefinition {
return {
name: "record_shipment",
description:
"Record a shipment. Every argument is optional AND explicitly nullable (Zod .nullish()), so each compiles to an anyOf union with a null branch.",
inputSchema: {
direction: z
.enum(["envio", "recebimento"])
.nullish()
.describe("Direction of the shipment (nullable enum — #1928)"),
reference: z.string().nullish().describe("Free-text reference"),
quantity: z.number().int().nullish().describe("Number of packages"),
express: z.boolean().nullish().describe("Ship express"),
},
handler: async (params: Record<string, unknown>) => {
return toToolResult(JSON.stringify(params, null, 2));
},
};
}
/**
* Create a "get-env" tool matching @modelcontextprotocol/server-everything.
* Returns the server process environment as pretty-printed JSON text.
*/
export function createGetEnvTool(): ToolDefinition {
return {
name: "get-env",
description:
"Returns all environment variables, helpful for debugging MCP server configuration",
inputSchema: {},
handler: async () => {
return toToolResult(JSON.stringify(process.env, null, 2));
},
};
}
/**
* Create a tool that writes a message to stderr. Used to test stderr capture/piping.
*/
export function createWriteToStderrTool(): ToolDefinition {
return {
name: "write_to_stderr",
description: "Write a message to stderr (for testing stderr capture)",
inputSchema: {
message: z.string().describe("Message to write to stderr"),
},
handler: async (params: Record<string, unknown>) => {
const msg = params.message as string;
process.stderr.write(`${msg}\n`);
return toToolResult(`Wrote to stderr: ${msg}`);
},
};
}
/**
* Create an "add" tool that adds two numbers together
*/
export function createAddTool(): ToolDefinition {
return {
name: "add",
description: "Add two numbers together",
inputSchema: {
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
},
handler: async (
params: Record<string, unknown>,
_context?: TestServerContext,
) => {
const a = params.a as number;
const b = params.b as number;
return toToolResult(JSON.stringify({ result: a + b }));
},
};
}
/**
* Create a "get_weather" tool carrying a SEP-2243 `x-mcp-header` annotation on
* its `city` argument, so a modern client mirrors the value into a
* `Mcp-Param-City` header (Base64-sentinel-encoded when the value is non-ASCII).
* Used by the `modern-network-http` showcase to exercise the Network tab's
* `Mcp-Param-*` decode.
*/
export function createGetWeatherTool(): ToolDefinition {
return {
name: "get_weather",
description:
"Get the weather for a city (its `city` argument mirrors to Mcp-Param-City)",
inputSchema: {
city: z.string().describe("City name").meta({ "x-mcp-header": "City" }),
},
handler: async (params: Record<string, unknown>) => {
return toToolResult(`Weather in ${params.city as string}: sunny, 24°C`);
},
};
}
/**
* Create a tool whose SEP-2243 `x-mcp-header` annotation is INVALID: the header
* name `"Bad Header"` contains a space, so it is not a valid RFC 9110 token.
* The whole tool definition is therefore invalid, and a conforming Streamable
* HTTP client MUST exclude it from `tools/list`. The server still serves it in
* the raw list (it only warns), so the Inspector can re-list raw and surface it
* as excluded with the reason (#1632).
*/
export function createInvalidHeaderTool(): ToolDefinition {
return {
name: "invalid_header_tool",
description:
"A tool with an invalid x-mcp-header annotation; conforming clients exclude it.",
inputSchema: {
value: z
.string()
.describe("A value")
.meta({ "x-mcp-header": "Bad Header" }),
},
handler: async () => toToolResult("should have been excluded"),
};
}
/**
* Create a no-op "trigger" tool whose `tools/call` is intercepted by the modern
* leg's spec-error injector (`injectSpecErrors`) to return a crafted
* SEP-2243/SEP-2575 error. The handler is a harmless fallback for the
* (never-reached) case where the injector is off.
*/
export function createSpecErrorTriggerTool(
name: string,
description: string,
): ToolDefinition {
return {
name,
description,
inputSchema: {},
handler: async () =>
toToolResult(`(${name}) injector disabled — no error returned`),
};
}
/**
* Create a "get_sum" tool that returns the sum of two numbers (alias for add)
*/
export function createGetSumTool(): ToolDefinition {
return {
name: "get_sum",
description: "Get the sum of two numbers",
inputSchema: {
a: z.number().describe("First number"),
b: z.number().describe("Second number"),
},
handler: async (
params: Record<string, unknown>,
_context?: TestServerContext,
) => {
const a = params.a as number;
const b = params.b as number;
return toToolResult(JSON.stringify({ result: a + b }));
},
};
}
/**
* Create a "collect_sample" tool that sends a sampling request and returns the response
*/
export function createCollectSampleTool(): ToolDefinition {
return {
name: "collect_sample",
description:
"Send a sampling request with the given text and return the response",
inputSchema: {
text: z.string().describe("Text to send in the sampling request"),
},
handler: async (
params: Record<string, unknown>,
context?: TestServerContext,
): Promise<CallToolResult> => {
if (!context) {
throw new Error("Server context not available");
}
const server = context.server;
const text = params.text as string;
// Send a sampling/createMessage request to the client using the SDK's createMessage method
try {
const result = await server.server.createMessage({
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: text,
},
},
],
maxTokens: 100, // Required parameter
});
return toToolResult(`Sampling response: ${JSON.stringify(result)}`);
} catch (error) {
console.error(
"[collect_sample] Error sending/receiving sampling request:",
error,
);
throw error;
}
},
};
}
/**
* Create a "list_roots" tool that calls roots/list and returns the roots
*/
export function createListRootsTool(): ToolDefinition {
return {
name: "list_roots",
description: "List the current roots configured on the client",
inputSchema: {},
handler: async (
_params: Record<string, unknown>,
context?: TestServerContext,
): Promise<CallToolResult> => {
if (!context) {
throw new Error("Server context not available");
}
const server = context.server;
try {
// Call roots/list on the client using the SDK's listRoots method
const result = await server.server.listRoots();
return toToolResult(`Roots: ${JSON.stringify(result.roots, null, 2)}`);
} catch (error) {
return toToolResult(
`Error listing roots: ${error instanceof Error ? error.message : String(error)}`,
true,
);
}
},
};
}
/**
* Create a "collectElicitation" tool that sends an elicitation request and returns the response
*/
export function createCollectFormElicitationTool(): ToolDefinition {
return {
name: "collect_elicitation",
description:
"Send an elicitation request with the given message and schema and return the response",
inputSchema: {
message: z
.string()
.describe("Message to send in the elicitation request"),
schema: z.unknown().describe("JSON schema for the elicitation request"),
},
handler: async (
params: Record<string, unknown>,
context?: TestServerContext,
): Promise<CallToolResult> => {
if (!context) {
throw new Error("Server context not available");
}
const server = context.server;
const message = params.message as string;
const schema =
params.schema as ElicitRequestFormParams["requestedSchema"];
// Send a form-based elicitation request using the SDK's elicitInput method
try {
const elicitationParams: ElicitRequestFormParams = {
message,
requestedSchema: schema,
};
const result = await server.server.elicitInput(elicitationParams);
return toToolResult(`Elicitation response: ${JSON.stringify(result)}`);
} catch (error) {
console.error(
"[collectElicitation] Error sending/receiving elicitation request:",
error,
);
throw error;
}
},
};
}
/**
* Create an "mrtr_confirm" tool exercising the modern (2026-07-28) multi
* round-trip request (MRTR) flow. On the first call it returns an
* `input_required` result embedding a form elicitation ("Confirm: <action>?");
* the client fulfils it and retries with `inputResponses`, on which the handler
* returns the final result. This is the modern replacement for a server→client
* `elicitation/create` request — `createCollectFormElicitationTool` (which calls
* `server.elicitInput`) is legacy-only and errors on the 2026-07-28 leg.
*
* Used by `test-servers/configs/modern-mrtr-http.json` so the Inspector's
* History view can render a real MRTR round-trip as one grouped conversation.
*/
// Process-wide counter so each original MRTR call mints a DISTINCT
// `requestState`. It must live at MODULE scope, not inside `createMrtrTool` or
// its handler closure: the modern (2026-07-28) leg is stateless — the SDK's
// `createMcpHandler` rebuilds the server (and every tool closure) per request
// (see `test-server-http.ts` `startModernHttp`), so a per-closure counter would
// reset to 0 on every request and every token would end in `:1`, defeating the
// point. A stable-per-action token would let two same-action calls (whose rounds
// land adjacently in the History log) fold into a single `MrtrConversation`,
// since grouping clusters contiguous entries sharing a token — confusing for a
// demo whose whole point is eyeballing the grouping. Only bumped on the mint
// (first) round; the retry echoes the token, it isn't re-minted. Real SDK tokens
// are already unique per operation.
let mrtrMintCount = 0;
export function createMrtrTool(): ToolDefinition {
return {
name: "mrtr_confirm",
description:
"Multi round-trip tool: asks the client to confirm an action via an embedded elicitation, then completes.",
inputSchema: {
action: z
.string()
.describe("The action to confirm before the tool completes"),
},
handler: async (
params: Record<string, unknown>,
_context?: TestServerContext,
extra?: HandlerExtra,
) => {
const action =
typeof params.action === "string" ? params.action : "the action";
const responses = extra?.inputResponses;
// First round: no answers yet — return input_required embedding a form
// elicitation and an opaque requestState the client echoes on retry.
if (!responses || responses.confirm === undefined) {
return inputRequired({
inputRequests: {
confirm: inputRequired.elicit({
message: `Confirm: ${action}?`,
requestedSchema: {
type: "object",
properties: {
confirm: { type: "boolean", title: "Confirm" },
},
required: ["confirm"],
},
}),
},
requestState: `mrtr:${action}:${++mrtrMintCount}`,
});
}
// Retry round: the client fulfilled the elicitation and echoed the answer.
return toToolResult(
`MRTR complete — confirmation for "${action}": ${JSON.stringify(responses.confirm)}`,
);
},
};
}
/**
* A two-round MRTR tool: it asks for a first value, then (on the retry) a second
* value, then completes. Exercises the manual driver's loop across MORE than one
* `input_required` round.
*
* The modern leg is stateless and each retry carries only THAT round's
* `inputResponses` (spec-correct — the client does not accumulate prior
* answers). So the tool tracks which step it is on via the opaque `requestState`
* the client echoes back, not via accumulated responses. Used to verify the
* Inspector surfaces each embedded elicitation in turn and only completes after
* both are answered (#1704).
*/
export function createMrtrMultiRoundTool(): ToolDefinition {
return {
name: "mrtr_two_step",
description:
"Two-round MRTR tool: collects a first value, then a second value, then completes.",
inputSchema: {},
handler: async (
_params: Record<string, unknown>,
_context?: TestServerContext,
extra?: HandlerExtra,
) => {
const step =
typeof extra?.requestState === "string" ? extra.requestState : "start";
if (step === "start") {
return inputRequired({
inputRequests: {
first: inputRequired.elicit({
message: "Step 1: enter the first value",
requestedSchema: {
type: "object",
properties: { value: { type: "string", title: "First" } },
required: ["value"],
},
}),
},
requestState: `mrtr-two:step2:${++mrtrMintCount}`,
});
}
if (step.startsWith("mrtr-two:step2")) {
return inputRequired({
inputRequests: {
second: inputRequired.elicit({
message: "Step 2: enter the second value",
requestedSchema: {
type: "object",
properties: { value: { type: "string", title: "Second" } },
required: ["value"],
},
}),
},
requestState: `mrtr-two:done:${++mrtrMintCount}`,
});
}
return toToolResult(
`MRTR two-step complete — final answer: ${JSON.stringify(
extra?.inputResponses?.second,
)}`,
);
},
};
}
/**
* An MRTR tool that embeds a `roots/list` request. The Inspector auto-answers it
* from the configured roots (no pending UX), so the retry carries the client's
* `{ roots }` result and the tool completes reporting how many roots it saw.
* Used to verify silent roots fulfilment mid-MRTR (#1704).
*/
export function createMrtrRootsTool(): ToolDefinition {
return {
name: "mrtr_roots",
description:
"MRTR tool that asks the client for its roots, then completes reporting the count.",
inputSchema: {},
handler: async (
_params: Record<string, unknown>,
_context?: TestServerContext,
extra?: HandlerExtra,
) => {
const responses = extra?.inputResponses;
if (!responses || responses.roots === undefined) {
return inputRequired({
inputRequests: { roots: inputRequired.listRoots() },
requestState: `mrtr-roots:${++mrtrMintCount}`,
});
}
const rootsResult = responses.roots as { roots?: unknown[] };
const count = Array.isArray(rootsResult.roots)
? rootsResult.roots.length
: 0;
return toToolResult(
`MRTR roots complete — client reported ${count} root(s)`,
);
},
};
}
/**
* An MRTR tool that embeds a `sampling/createMessage` request. On the retry the
* client's sampling result is echoed back and the tool completes. Verifies the
* driver surfaces an embedded sampling request through the pending-request UI
* the same way it does an elicitation (#1704).
*/
export function createMrtrSamplingTool(): ToolDefinition {
return {
name: "mrtr_sample",
description:
"MRTR tool that asks the client to sample an LLM completion, then completes.",
inputSchema: {},
handler: async (
_params: Record<string, unknown>,
_context?: TestServerContext,
extra?: HandlerExtra,
) => {
const responses = extra?.inputResponses;
if (!responses || responses.sample === undefined) {
return inputRequired({
inputRequests: {
sample: inputRequired.createMessage({
messages: [
{
role: "user",
content: { type: "text", text: "Say hello." },
},
],
maxTokens: 64,
}),
},
requestState: `mrtr-sample:${++mrtrMintCount}`,
});
}
return toToolResult(
`MRTR sample complete — echoed: ${JSON.stringify(responses.sample)}`,
);
},
};
}
/**
* A pathological MRTR tool that ALWAYS returns `input_required` and never
* completes. Used to verify the manual driver bounds the loop (its
* `MRTR_MAX_ROUNDS` cap) instead of spinning forever (#1704).
*/
export function createMrtrLoopTool(): ToolDefinition {
return {
name: "mrtr_loop",
description:
"Pathological MRTR tool that never completes — always returns input_required.",
inputSchema: {},
handler: async () => {
return inputRequired({
inputRequests: {
again: inputRequired.elicit({
message: "Answer again (this never completes)",
requestedSchema: {
type: "object",
properties: { value: { type: "string", title: "Value" } },
},
}),
},
requestState: `mrtr-loop:${++mrtrMintCount}`,
});
},
};
}
/**
* An MRTR tool exercising the driver's param-shaping edge cases (#1704):
* - Round 1 embeds an elicitation but mints NO `requestState` (so the retry
* carries `inputResponses` and no `requestState`).
* - Round 2 is `requestState`-only: it carries a `requestState` and NO
* `inputRequests` (so the retry carries `requestState` and no
* `inputResponses`).
* - Round 3 completes.
*/
export function createMrtrEdgeCaseTool(): ToolDefinition {
return {
name: "mrtr_edge",
description:
"MRTR tool covering the inputRequests-only and requestState-only round shapes.",
inputSchema: {},
handler: async (
_params: Record<string, unknown>,
_context?: TestServerContext,
extra?: HandlerExtra,
) => {
const state =
typeof extra?.requestState === "string" ? extra.requestState : "";
// Round 2: the client answered round 1's elicitation. Bounce a
// requestState-only round (no embedded requests) to move to the final leg.
if (extra?.inputResponses?.note !== undefined) {
return inputRequired({
requestState: `mrtr-edge:final:${++mrtrMintCount}`,
});
}
// Round 3: the requestState-only round came back — complete.
if (state.startsWith("mrtr-edge:final")) {
return toToolResult("MRTR edge complete");
}
// Round 1: embed an elicitation with NO requestState.
return inputRequired({
inputRequests: {
note: inputRequired.elicit({
message: "Edge round 1: enter a note",
requestedSchema: {
type: "object",
properties: { value: { type: "string", title: "Note" } },
},
}),
},
});
},
};
}
/**
* Create a "url_elicitation_form" tool that spins up a simple HTTP server on a dynamic
* port with a form page, sends that URL via URL elicitation, and on form submit collects
* the text input, includes it in the tool response, and closes the server.
*/
export function createUrlElicitationFormTool(): ToolDefinition {
return {
name: "url_elicitation_form",
description:
"Present a form via URL elicitation; collects submitted text and returns it in the tool response",
inputSchema: {
message: z
.string()
.optional()
.describe(
"Message to show in the elicitation (default: prompt for input)",
),
},
handler: async (
params: Record<string, unknown>,
context?: TestServerContext,
): Promise<CallToolResult> => {
if (!context) {
throw new Error("Server context not available");
}
const server = context.server;
const message =
(params.message as string) || "Please submit a value in the form";
const elicitationId = `url-form-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
let resolveFormData!: (value: string) => void;
const formDataPromise = new Promise<string>((resolve) => {
resolveFormData = resolve;
});
const completionNotifier =
server.server.createElicitationCompletionNotifier(elicitationId);
const { createServer } = await import("node:http");
const { createServer: createNetServer } = await import("node:net");
const formHtml = (elicitationId: string) => `
<!DOCTYPE html>
<html>
<head><title>Submit Value</title></head>
<body>
<form method="POST" action="/">
<input type="hidden" name="elicitation" value="${elicitationId}" />
<label>Value: <input type="text" name="value" required /></label>
<button type="submit">Submit</button>
</form>
</body>
</html>`;
const successHtml = `
<!DOCTYPE html>
<html>
<head><title>Submitted</title></head>
<body><p>Submitted. You can close this window.</p></body>
</html>`;
const httpServer = createServer((req, res) => {
if (req.method === "GET" && req.url === "/") {
res.writeHead(200, { "Content-Type": "text/html" });
res.end(formHtml(elicitationId));
return;
}
if (req.method === "POST" && req.url === "/") {
let body = "";
req.on("data", (chunk) => {
body += chunk.toString();
});
req.on("end", () => {
const params = new URLSearchParams(body);
const value = params.get("value") ?? "";
completionNotifier().catch(() => {});
resolveFormData(value);
httpServer.close();
res.writeHead(200, { "Content-Type": "text/html" });
res.end(successHtml);
});
return;
}
res.writeHead(404);
res.end();
});
const port = await new Promise<number>((resolve, reject) => {
const s = createNetServer();
s.listen(0, "127.0.0.1", () => {
const addr = s.address() as { port: number };
s.close(() => resolve(addr.port));
});
s.on("error", reject);
});
httpServer.listen(port, "127.0.0.1");
const url = `http://127.0.0.1:${port}/`;
try {
const result = await server.server.elicitInput({
mode: "url",
message,
elicitationId,
url,
});
if (result.action !== "accept") {
httpServer.close();
return toToolResult(
`Elicitation ${result.action}: user did not accept`,
);
}
const collectedValue = await formDataPromise;
return toToolResult(`Collected value: ${collectedValue}`);
} catch (error) {
httpServer.close();
throw error;
}
},
};
}
/**
* Create a "collect_url_elicitation" tool that sends a URL-based elicitation request
* to the client and returns the response
*/
export function createCollectUrlElicitationTool(): ToolDefinition {
return {
name: "collect_url_elicitation",
description:
"Send a URL-based elicitation request with the given message and URL and return the response",
inputSchema: {
message: z
.string()
.describe("Message to send in the elicitation request"),
url: z.string().url().describe("URL for the user to navigate to"),
elicitationId: z
.string()
.optional()
.describe("Optional elicitation ID (generated if not provided)"),
},
handler: async (
params: Record<string, unknown>,
context?: TestServerContext,
): Promise<CallToolResult> => {
if (!context) {
throw new Error("Server context not available");
}
const server = context.server;
const message = params.message as string;
const url = params.url as string;
const elicitationId =
(params.elicitationId as string) ||
`url-elicitation-${Date.now()}-${Math.random()}`;
// Send a URL-based elicitation request using the SDK's elicitInput method
try {
const elicitationParams: ElicitRequestURLParams = {
mode: "url",
message,
elicitationId,
url,
};
const result = await server.server.elicitInput(elicitationParams);
return toToolResult(
`URL elicitation response: ${JSON.stringify(result)}`,
);
} catch (error) {
console.error(
"[collect_url_elicitation] Error sending/receiving URL elicitation request:",
error,
);
throw error;
}
},
};
}
/**
* Create a "send_notification" tool that sends a notification message from the server
*/
export function createSendNotificationTool(): ToolDefinition {
return {
name: "send_notification",
description: "Send a notification message from the server",
inputSchema: {
message: z.string().describe("Notification message to send"),
level: z
.enum([
"debug",
"info",
"notice",
"warning",
"error",
"critical",
"alert",
"emergency",
])
.optional()
.describe("Log level for the notification"),
},
handler: async (
params: Record<string, unknown>,
context?: TestServerContext,
extra?: HandlerExtra,
): Promise<CallToolResult> => {
if (!context) {
throw new Error("Server context not available");
}
const message = params.message as string;
const level = (params.level as string) || "info";
// Emit the log through the SDK's request-scoped, threshold-gated
// `extra.log` (`ctx.mcpReq.log`) when available. It applies the era-correct
// gating for us: on the modern (2026-07-28) leg it drops the message unless
// the client opted in via the per-request `logLevel` `_meta` (and honors
// that level's severity), and streams the admitted log on THIS request's
// SSE response; on legacy it honors the session level from
// `logging/setLevel`. The global `server.server.notification()` fallback is
// for any caller without per-request context (older/in-process paths) and
// emits unconditionally on the session transport.
try {
if (extra?.log) {
await extra.log(level, { message }, "test-server");
} else {
await context.server.server.notification({
method: "notifications/message",
params: { level, logger: "test-server", data: { message } },
});
}
return toToolResult(`Notification sent: ${message}`);
} catch (error) {
console.error("[send_notification] Error sending notification:", error);
throw error;
}
},
};
}
/**
* Create a "get-annotated-message" tool that returns a message with optional image
*/
export function createGetAnnotatedMessageTool(): ToolDefinition {
return {
name: "get_annotated_message",
description: "Get an annotated message",
inputSchema: {
messageType: z
.enum(["success", "error", "warning", "info"])
.describe("Type of message"),
includeImage: z
.boolean()
.optional()
.describe("Whether to include an image"),
},
handler: async (
params: Record<string, unknown>,
_context?: TestServerContext,
): Promise<CallToolResult> => {
const messageType = params.messageType as string;
const includeImage = params.includeImage as boolean | undefined;
const message = `This is a ${messageType} message`;
const content: Array<
| { type: "text"; text: string }
| { type: "image"; data: string; mimeType: string }
> = [
{
type: "text",
text: message,
},
];
if (includeImage) {
content.push({
type: "image",
data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==", // 1x1 transparent PNG
mimeType: "image/png",
});
}
return { content };
},
};
}
/** Output schema for get_temp: temperature, unit, city */
const GetTempOutputSchema = z.object({
temperature: z.number().describe("Temperature value"),
unit: z.string().describe("C or F"),
city: z.string().describe("City name"),
});
/**
* Create a "get_temp" tool that returns both content (human-readable) and structuredContent (schema-validated).
* Takes city and units (C/F), returns mock temperature 25 and matching text + structured output.
*/
export function createGetTempTool(): ToolDefinition {
return {
name: "get_temp",
description:
"Get the current temperature for a city (mock; returns 25 in requested units)",
inputSchema: {
city: z.string().describe("City name"),
units: z.enum(["C", "F"]).describe("Temperature units"),
},
outputSchema: GetTempOutputSchema,
handler: async (params: Record<string, unknown>) => {
const city = (params.city as string) || "Unknown";
const unit = (params.units as "C" | "F") || "C";
const temperature = 25;
const text = `The temperature in ${city} is ${temperature} degrees ${unit}`;
return {
content: [{ type: "text" as const, text }],
structuredContent: { temperature, unit, city },
};
},
};
}
/** Output schema for list_items: a nested list of items plus a total. */
const ListItemsOutputSchema = z.object({
items: z
.array(
z.object({
id: z.number().describe("Item id"),
name: z.string().describe("Item name"),
tags: z.array(z.string()).describe("Item tags"),
}),
)
.describe("The items"),
total: z.number().describe("Number of items"),
});
/**
* Create a "list_items" tool returning a short `content` summary alongside a
* DEEPLY NESTED `structuredContent` (objects inside arrays inside an object) —
* the shape from #1908, where the structured payload carries everything the
* text block only summarizes. Exercises the Structured Output section of the
* Tools screen result panel, which `get_temp`'s flat three-key object does not.
*/
export function createListItemsTool(): ToolDefinition {
return {
name: "list_items",
description: "Returns a list of items with nested structured output",
inputSchema: {},
outputSchema: ListItemsOutputSchema,
handler: async () => {
const items = [
{ id: 1, name: "Item A", tags: ["foo", "bar"] },
{ id: 2, name: "Item B", tags: ["baz"] },
];
return {
content: [
{ type: "text" as const, text: `Found ${items.length} items.` },
],
structuredContent: { items, total: items.length },
};
},
};
}
/**
* Create a "get_temp_extra" tool that declares the same output schema as
* get_temp but returns an EXTRA, undeclared property in structuredContent.
*
* The SDK server validates output via zod safeParse (which strips unknown keys
* and passes), then sends the ORIGINAL structuredContent — so the extra key
* reaches the wire. The SDK *client* validates that payload against the strict
* JSON schema derived from the output schema and rejects it
* ("must NOT have additional properties"). This mirrors real-world servers
* (e.g. MCP App tools) whose results legacy hosts render but whose strict
* client validation otherwise denies. Used to exercise
* InspectorClient.callTool's `skipOutputValidation` option.
*/
export function createGetTempExtraTool(): ToolDefinition {
return {
name: "get_temp_extra",
description:
"Like get_temp, but returns an extra structuredContent property that violates the output schema (for validation-bypass testing)",
inputSchema: {
city: z.string().describe("City name"),
units: z.enum(["C", "F"]).describe("Temperature units"),
},
outputSchema: GetTempOutputSchema,
handler: async (params: Record<string, unknown>) => {
const city = (params.city as string) || "Unknown";
const unit = (params.units as "C" | "F") || "C";
const temperature = 25;
const text = `The temperature in ${city} is ${temperature} degrees ${unit}`;
return {
content: [{ type: "text" as const, text }],
// `extra` is not in GetTempOutputSchema → strict client validation fails.
structuredContent: { temperature, unit, city, extra: "undeclared" },
};
},
};
}
/**
* Create a "simple_prompt" prompt definition
*/
export function createSimplePrompt(): PromptDefinition {
return {
name: "simple_prompt",
description: "A simple prompt for testing",
promptString: "This is a simple prompt for testing purposes.",
};
}
/**
* Create an "args_prompt" prompt that accepts arguments
*/
export function createArgsPrompt(
completions?: Record<
string,
(
argumentValue: string,
context?: Record<string, string>,
) => Promise<string[]> | string[]
>,
): PromptDefinition {
return {
name: "args_prompt",
description: "A prompt that accepts arguments for testing",
promptString: "This is a prompt with arguments: city={city}, state={state}",
argsSchema: {
city: z.string().describe("City name"),
state: z.string().describe("State name"),
},
completions,
};
}
/** Canonical URI for the {@link createMcpAppDemoResource} UI resource, referenced by {@link createMcpAppDemoTool}'s `_meta.ui.resourceUri`. Exported so tests can assert against it without redefining the literal. */
export const MCP_APP_DEMO_URI = "ui://demo/widget.html";
/**
* Minimal MCP App widget that exercises the host-side UI protocol surface
* (size-changed, ui/message, log notification, host-context render). Kept as a
* single inline HTML string with no external scripts so the sandbox CSP's
* locked-down defaults are sufficient.
*
* The lone `rgba(0,0,0,0.06)` is a deliberate exception to the AGENTS.md
* color-token rule: this is a self-contained static fixture served into the
* sandbox iframe, not a Mantine component, so the `--inspector-*` CSS custom
* properties (defined in the web client's App.css) are not in scope here.
*/
const MCP_APP_DEMO_HTML = `<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>mcp-app-demo widget</title>
<style>
body { margin: 0; font-family: system-ui, sans-serif; padding: 16px; }
pre { background: rgba(0,0,0,0.06); padding: 8px; border-radius: 6px; }
</style>
</head>
<body>
<h2 id="title">mcp-app-demo</h2>
<pre id="ctx">waiting for ui/initialize…</pre>
<script type="module">
// Per spec, ui/initialize is a View→Host REQUEST: the view sends it on
// load and the host responds with hostContext + capabilities. The view
// then sends notifications/initialized once it has rendered.
// Production apps should use @modelcontextprotocol/ext-apps (the App
// class handles the handshake and origin discipline internally) — this
// fixture is a minimal no-SDK demo of the raw protocol for testing the
// host side.
const INIT_ID = 1;
let nextId = 2;
// Captured from the host's ui/initialize RESPONSE; thereafter every send
// targets, and every receive is checked against, this exact origin so a
// sibling frame on a different origin cannot inject or intercept traffic.
let HOST_ORIGIN = null;
const send = (msg) =>
window.parent.postMessage(
{ jsonrpc: "2.0", ...msg },
HOST_ORIGIN ?? "*",
);
let lastCtx = {};
const renderCtx = (patch) => {
// host-context-changed carries a PARTIAL (only changed fields per
// spec); merge into the running snapshot so unchanged fields persist.
lastCtx = { ...lastCtx, ...patch };
document.getElementById("ctx").textContent = JSON.stringify(
{
theme: lastCtx.theme,
displayMode: lastCtx.displayMode,
containerDimensions: lastCtx.containerDimensions,
},
null,
2,
);
};
const onInitialized = (hostContext) => {
renderCtx(hostContext);
// Signal the view is ready (host gates view→host requests on this).
send({ method: "ui/notifications/initialized" });
// Standard MCP log notification — surfaced by the host's log panel.
send({
method: "notifications/message",
params: { level: "info", data: "mcp-app-demo initialized" },
});
// Tell the host the rendered content height.
send({
method: "ui/notifications/size-changed",
params: {
width: document.body.clientWidth,
height: document.body.scrollHeight,
},
});
// Submit one user-role message via ui/message.
send({
id: nextId++,
method: "ui/message",
params: {
content: [{ type: "text", text: "hello from mcp-app-demo" }],
},
});
};
window.addEventListener("message", (ev) => {
if (HOST_ORIGIN !== null && ev.origin !== HOST_ORIGIN) return;
const m = ev.data;
if (!m || m.jsonrpc !== "2.0") return;
if (m.id === INIT_ID && m.result) {
HOST_ORIGIN = ev.origin;
onInitialized(m.result.hostContext);
} else if (m.method === "ui/notifications/host-context-changed") {
// params IS the partial McpUiHostContext (spec.types.d.ts:290).
renderCtx(m.params);
}
});
// Kick off the handshake.
send({
id: INIT_ID,
method: "ui/initialize",
params: {
protocolVersion: "2026-01-26",
appInfo: { name: "mcp-app-demo", version: "1.0.0" },
appCapabilities: {},
},
});
</script>
</body>
</html>`;
/**
* Tool definition for the MCP App demo. Carries `_meta.ui.resourceUri` so
* clients recognize it as an App tool; the call result echoes the input title
* so the rendered widget can be visually correlated with the call.
*/
export function createMcpAppDemoTool(): ToolDefinition {
return {
name: "mcp_app_demo",
description:
"Render a minimal MCP App widget that exercises size-changed, ui/message, logging, and host-context rendering.",
inputSchema: {
title: z.string().describe("Heading shown in the rendered widget"),
},
_meta: {
ui: { resourceUri: MCP_APP_DEMO_URI, visibility: ["model", "app"] },
},
handler: async (params: Record<string, unknown>) => {
return toToolResult(
`mcp_app_demo rendered with title="${String(params.title)}"`,
);
},
};
}
/**
* UI resource for {@link createMcpAppDemoTool}. Declares a permissive
* `_meta.ui.csp` (no external connect/resource domains) and a sample
* `permissions` block so `--app-info` and the host's CSP enforcement both have
* something to read.
*/
export function createMcpAppDemoResource(): ResourceDefinition {
return {
name: "mcp_app_demo_widget",
uri: MCP_APP_DEMO_URI,
description: "Inline HTML widget for the mcp_app_demo tool",
mimeType: "text/html",
text: MCP_APP_DEMO_HTML,
_meta: {
ui: {
csp: { connectDomains: [], resourceDomains: [] },
permissions: { clipboard: false },
prefersBorder: true,
},
},
};
}
/**
* Create an "architecture" resource definition
*/
export function createArchitectureResource(): ResourceDefinition {
return {
name: "architecture",
uri: "demo://resource/static/document/architecture.md",
description: "Architecture documentation",
mimeType: "text/markdown",
text: `# Architecture Documentation
This is a test resource for the MCP test server.
## Overview
This resource is used for testing resource reading functionality in the CLI.
## Sections
- Introduction
- Design
- Implementation
- Testing
## Notes
This is a static resource provided by the test MCP server.
`,
};
}
/**
* Create a "test_cwd" resource that exposes the current working directory (generally useful when testing with the stdio test server)
*/
export function createTestCwdResource(): ResourceDefinition {
return {
name: "test_cwd",
uri: "test://cwd",
description: "Current working directory of the test server",
mimeType: "text/plain",
text: process.cwd(),
};
}
/**
* Create a "test_env" resource that exposes environment variables (generally useful when testing with the stdio test server)
*/
export function createTestEnvResource(): ResourceDefinition {
return {
name: "test_env",
uri: "test://env",
description: "Environment variables available to the test server",
mimeType: "application/json",
text: JSON.stringify(process.env, null, 2),
};
}
/**
* Create a "test_argv" resource that exposes command-line arguments (generally useful when testing with the stdio test server)
*/
export function createTestArgvResource(): ResourceDefinition {
return {
name: "test_argv",
uri: "test://argv",
description: "Command-line arguments the test server was started with",
mimeType: "application/json",
text: JSON.stringify(process.argv, null, 2),
};
}
/**
* Create minimal server info for test servers
*/
export function createTestServerInfo(
name: string = "test-server",
version: string = "1.0.0",
): Implementation {
return {
name,
version,
};
}
/**
* Create a "file" resource template that reads files by path
*/
export function createFileResourceTemplate(
completionCallback?: (
argumentName: string,
value: string,
context?: Record<string, string>,
) => Promise<string[]> | string[],
listCallback?: () => Promise<string[]> | string[],
): ResourceTemplateDefinition {
return {
name: "file",
uriTemplate: "file:///{path}",
description: "Read a file by path",
inputSchema: {
path: z.string().describe("File path to read"),
},
handler: async (uri: URL, params: Record<string, unknown>) => {
const path = params.path as string;
// For testing, return a mock file content
return {
contents: [
{
uri: uri.toString(),
mimeType: "text/plain",
text: `Mock file content for: ${path}\nThis is a test resource template.`,
},
],
};
},
complete: completionCallback,
list: listCallback,
};
}
/**
* Resource templates that exercise RFC 6570 expansion (#1919).
*
* `events_by_topic` is the simple `{topic}` expression from the issue: a `/`,
* `?`, `#` or space in the value MUST be percent-encoded, or the URI gains a
* path segment and a conforming matcher rejects it. `events_by_query` is the
* `{?topic}` form, which the web client could not even render an input for.
*
* Both handlers echo the URI they were matched against plus the variables the
* server decoded, so the round-trip is visible in the read result.
*/
/**
* The plain `foobar://events` resource that a blank `{?topic}` read lands on.
*
* Registering it is not decoration. RFC 6570 omits a query expression whose
* variable is undefined, so leaving `topic` blank legitimately requests the
* unfiltered collection -- but the SDK's own matcher cannot serve that from the
* template: `UriTemplate.partToRegExp` compiles `{?topic}` to a **required**
* `\?topic=([^&]+)`, so `match("foobar://events")` returns null and the read
* would 404. A real server would expose the collection as its own resource;
* this fixture does the same so the showcase's "read it blank" step works.
*/
export function createRfc6570BaseResource(): ResourceDefinition {
return {
uri: "foobar://events",
name: "events",
description: "All events - what a blank `{?topic}` read resolves to",
mimeType: "application/json",
text: JSON.stringify({ collection: "events", filtered: false }, null, 2),
};
}
export function createRfc6570ResourceTemplates(): ResourceTemplateDefinition[] {
const echo =
(label: string) => async (uri: URL, params: Record<string, unknown>) => ({
contents: [
{
uri: uri.toString(),
mimeType: "application/json",
text: JSON.stringify(
{ template: label, matchedUri: uri.toString(), variables: params },
null,
2,
),
},
],
});
return [
{
name: "events_by_topic",
uriTemplate: "foobar://events/{topic}",
description:
"Simple expression - a reserved character in `topic` must be percent-encoded",
inputSchema: { topic: z.string().describe("Topic name") },
handler: echo("foobar://events/{topic}"),
},
{
name: "events_by_query",
uriTemplate: "foobar://events{?topic}",
description:
"Query expression - optional, and omitted entirely when `topic` is blank",
inputSchema: { topic: z.string().describe("Topic name") },
handler: echo("foobar://events{?topic}"),
},
{
// A template no client can honor: `abc` is not RFC 6570's `max-length`
// production (`%x31-39 0*3DIGIT`), so `{topic:abc}` is a malformed
// *template* rather than one with an ignorable modifier. The SDK's
// `UriTemplate` constructor accepts it, which is exactly why the
// Inspector checks the grammar itself and refuses the read -- guessing
// `{topic}` would send a URI this server never advertised.
//
// The handler is therefore unreachable through the Inspector by design.
// It is registered anyway so the template appears in
// `resources/templates/list`, which is what puts the refusal on screen.
name: "events_malformed",
uriTemplate: "foobar://events/{topic:abc}",
description:
"Malformed template - an out-of-grammar prefix modifier; the read is withheld",
inputSchema: { topic: z.string().describe("Topic name") },
handler: echo("foobar://events/{topic:abc}"),
},
];
}
/**
* Create a "user" resource template that returns user data by ID
*/
export function createUserResourceTemplate(
completionCallback?: (
argumentName: string,
value: string,
context?: Record<string, string>,
) => Promise<string[]> | string[],
listCallback?: () => Promise<string[]> | string[],
): ResourceTemplateDefinition {
return {
name: "user",
uriTemplate: "user://{userId}",
description: "Get user data by ID",
inputSchema: {
userId: z.string().describe("User ID"),
},
handler: async (uri: URL, params: Record<string, unknown>) => {
const userId = params.userId as string;
return {
contents: [
{
uri: uri.toString(),
mimeType: "application/json",
text: JSON.stringify(
{
id: userId,
name: `User ${userId}`,
email: `user${userId}@example.com`,
role: "test-user",
},
null,
2,
),
},
],
};
},
complete: completionCallback,
list: listCallback,
};
}
/**
* Create a tool that adds a resource to the server and sends list_changed notification
*/
export function createAddResourceTool(): ToolDefinition {
return {
name: "add_resource",
description:
"Add a resource to the server and send list_changed notification",
inputSchema: {
uri: z.string().describe("Resource URI"),
name: z.string().describe("Resource name"),
description: z.string().optional().describe("Resource description"),
mimeType: z.string().optional().describe("Resource MIME type"),
text: z.string().optional().describe("Resource text content"),
},
handler: async (
params: Record<string, unknown>,
context?: TestServerContext,
) => {
if (!context) {
throw new Error("Server context not available");
}
const { server, state } = context;
// Register with SDK (returns RegisteredResource)
const registered = server.registerResource(
params.name as string,
params.uri as string,
{
description: params.description as string | undefined,
mimeType: params.mimeType as string | undefined,
},
async () => {
return {
contents: params.text
? [
{
uri: params.uri as string,
mimeType: params.mimeType as string | undefined,
text: params.text as string,
},
]
: [],
};
},
);
// Track in state (keyed by URI)
state.registeredResources.set(params.uri as string, registered);
// Send notification if capability enabled
if (state.listChangedConfig.resources) {
server.sendResourceListChanged();
}
return toToolResult(`Resource ${params.uri} added`);
},
};
}
/**
* Create a tool that removes a resource from the server by URI and sends list_changed notification
*/
export function createRemoveResourceTool(): ToolDefinition {
return {
name: "remove_resource",
description:
"Remove a resource from the server by URI and send list_changed notification",
inputSchema: {
uri: z.string().describe("Resource URI to remove"),
},
handler: async (
params: Record<string, unknown>,
context?: TestServerContext,
) => {
if (!context) {
throw new Error("Server context not available");
}
const { server, state } = context;
// Find registered resource by URI
const resource = state.registeredResources.get(params.uri as string);
if (!resource) {
throw new Error(`Resource with URI ${params.uri} not found`);
}
// Remove from SDK registry
resource.remove();
// Remove from tracking
state.registeredResources.delete(params.uri as string);
// Send notification if capability enabled
if (state.listChangedConfig.resources) {
server.sendResourceListChanged();
}
return toToolResult(`Resource ${params.uri} removed`);
},
};
}
/**
* Create a tool that adds a tool to the server and sends list_changed notification
*/
export function createAddToolTool(): ToolDefinition {
return {
name: "add_tool",
description: "Add a tool to the server and send list_changed notification",
inputSchema: {
name: z.string().describe("Tool name"),
description: z.string().describe("Tool description"),
inputSchema: z.unknown().optional().describe("Tool input schema"),
},
handler: async (
params: Record<string, unknown>,
context?: TestServerContext,
) => {
if (!context) {
throw new Error("Server context not available");
}
const { server, state } = context;
// Register with SDK (returns RegisteredTool)
const registered = server.registerTool(
params.name as string,
{
description: params.description as string,
inputSchema: params.inputSchema as
| Record<string, z.ZodType>
| undefined,
},
async () => {
return {
content: [
{
type: "text" as const,
text: `Tool ${params.name} executed`,
},
],
};
},
);
// Track in state (keyed by name)
state.registeredTools.set(params.name as string, registered);
// Send notification if capability enabled
// Note: sendToolListChanged() is synchronous on McpServer but internally calls async Server method
// We don't await it, but the tool should be registered before sending the notification
if (state.listChangedConfig.tools) {
// Small delay to ensure tool is fully registered in SDK's internal state
await new Promise((resolve) => setTimeout(resolve, 10));
server.sendToolListChanged();
}
return toToolResult(`Tool ${params.name} added`);
},
};
}
/**
* Create a tool that removes a tool from the server by name and sends list_changed notification
*/
export function createRemoveToolTool(): ToolDefinition {
return {
name: "remove_tool",
description:
"Remove a tool from the server by name and send list_changed notification",
inputSchema: {
name: z.string().describe("Tool name to remove"),
},
handler: async (
params: Record<string, unknown>,
context?: TestServerContext,
) => {
if (!context) {
throw new Error("Server context not available");
}
const { server, state } = context;
// Find registered tool by name
const tool = state.registeredTools.get(params.name as string);
if (!tool) {
throw new Error(`Tool ${params.name} not found`);
}
// Remove from SDK registry
tool.remove();
// Remove from tracking
state.registeredTools.delete(params.name as string);
// Send notification if capability enabled
if (state.listChangedConfig.tools) {
server.sendToolListChanged();
}
return toToolResult(`Tool ${params.name} removed`);
},
};
}
/**
* Create a tool that adds a prompt to the server and sends list_changed notification
*/
export function createAddPromptTool(): ToolDefinition {
return {
name: "add_prompt",
description:
"Add a prompt to the server and send list_changed notification",
inputSchema: {
name: z.string().describe("Prompt name"),
description: z.string().optional().describe("Prompt description"),
promptString: z.string().describe("Prompt text"),
argsSchema: z.unknown().optional().describe("Prompt arguments schema"),
},
handler: async (
params: Record<string, unknown>,
context?: TestServerContext,
) => {
if (!context) {
throw new Error("Server context not available");
}
const { server, state } = context;
// Register with SDK (returns RegisteredPrompt)
const registered = server.registerPrompt(
params.name as string,
{
description: params.description as string | undefined,
argsSchema: params.argsSchema as
| Record<string, z.ZodType>
| undefined,
},
async () => {
return {
messages: [
{
role: "user" as const,
content: {
type: "text" as const,
text: params.promptString as string,
},
},
],
};
},
);
// Track in state (keyed by name)
state.registeredPrompts.set(params.name as string, registered);
// Send notification if capability enabled
if (state.listChangedConfig.prompts) {
server.sendPromptListChanged();
}
return toToolResult(`Prompt ${params.name} added`);
},
};
}
/**
* Create a tool that updates an existing resource's content and sends resource updated notification
*/
export function createUpdateResourceTool(): ToolDefinition {
return {
name: "update_resource",
description:
"Update an existing resource's content and send resource updated notification",
inputSchema: {
uri: z.string().describe("Resource URI to update"),
text: z.string().describe("New resource text content"),
},
handler: async (
params: Record<string, unknown>,
context?: TestServerContext,
) => {
if (!context) {
throw new Error("Server context not available");
}
const { server, state } = context;
// Find registered resource by URI
const resource = state.registeredResources.get(params.uri as string);
if (!resource) {
throw new Error(`Resource with URI ${params.uri} not found`);
}
// Get the current resource metadata to preserve mimeType
const currentResource = state.registeredResources.get(
params.uri as string,
);
const mimeType = currentResource?.metadata?.mimeType || "text/plain";
// Update the resource's callback to return new content
resource.update({
callback: async () => {
return {
contents: [
{
uri: params.uri as string,
mimeType,
text: params.text as string,
},
],
};
},
});
// Send resource updated notification only if subscribed
const uri = params.uri as string;
if (state.resourceSubscriptions.has(uri)) {
await server.server.sendResourceUpdated({
uri,
});
}
return toToolResult(`Resource ${params.uri} updated`);
},
};
}
/**
* Create a tool that sends progress notifications during execution
* @param name Tool name (default: "send_progress")
* @returns Tool definition
*/
export function createSendProgressTool(
name: string = "send_progress",
): ToolDefinition {
return {
name,
description:
"Send progress notifications during tool execution, then return a result",
inputSchema: {
units: z
.number()
.int()
.positive()
.describe("Number of progress units to send"),
delayMs: z
.number()
.int()
.nonnegative()
.default(100)
.describe("Delay in milliseconds between progress notifications"),
total: z
.number()
.int()
.positive()
.optional()
.describe("Total number of units (for percentage calculation)"),
message: z
.string()
.optional()
.describe("Progress message to include in notifications"),
},
handler: async (
params: Record<string, unknown>,
context?: TestServerContext,
extra?: HandlerExtra,
): Promise<CallToolResult> => {
if (!context) {
throw new Error("Server context not available");
}
const server = context.server;
const units = params.units as number;
const delayMs = (params.delayMs as number) || 100;
const total = params.total as number | undefined;
const message = (params.message as string) || "Processing...";
// Extract progressToken from metadata
const progressToken = extra?._meta?.progressToken as
| string
| number
| undefined;
// Send progress notifications
let sent = 0;
for (let i = 1; i <= units; i++) {
if (context.serverControl?.isClosing()) {
break;
}
// Wait before sending notification (except for the first one)
if (i > 1 && delayMs > 0) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
if (context.serverControl?.isClosing()) {
break;
}
if (progressToken !== undefined) {
const progressParams: {
progress: number;
total?: number;
message?: string;
progressToken: string | number;
} = {
progress: i,
message: `${message} (${i}/${units})`,
progressToken,
};
if (total !== undefined) {
progressParams.total = total;
}
try {
await server.server.notification(
{
method: "notifications/progress",
params: progressParams,
},
{ relatedRequestId: extra?.requestId },
);
sent = i;
} catch (error) {
console.error(
"[send_progress] Error sending progress notification:",
error,
);
break;
}
}
}
return toToolResult(
`Completed ${sent} progress notifications (units: ${sent}, total: ${total ?? units})`,
);
},
};
}
export function createRemovePromptTool(): ToolDefinition {
return {
name: "remove_prompt",
description:
"Remove a prompt from the server by name and send list_changed notification",
inputSchema: {
name: z.string().describe("Prompt name to remove"),
},
handler: async (
params: Record<string, unknown>,
context?: TestServerContext,
) => {
if (!context) {
throw new Error("Server context not available");
}
const { server, state } = context;
// Find registered prompt by name
const prompt = state.registeredPrompts.get(params.name as string);
if (!prompt) {
throw new Error(`Prompt ${params.name} not found`);
}
// Remove from SDK registry
prompt.remove();
// Remove from tracking
state.registeredPrompts.delete(params.name as string);
// Send notification if capability enabled
if (state.listChangedConfig.prompts) {
server.sendPromptListChanged();
}
return toToolResult(`Prompt ${params.name} removed`);
},
};
}
/** Options for creating an immediate (non-task) tool that completes after a delay */
export interface ImmediateToolOptions {
name?: string; // default: "flexibleTask"
delayMs?: number; // default: 1000
}
/** Options for creating a task tool (createTask + getTask + getTaskResult) with optional progress, elicitation, sampling, etc. */
export interface TaskToolOptions {
name?: string; // default: "flexibleTask"
taskSupport?: "required" | "optional"; // default: "required"
delayMs?: number; // default: 1000 (time before task completes)
progressUnits?: number; // If provided, send progress notifications
elicitationSchema?: z.ZodTypeAny; // If provided, require elicitation with this schema
samplingText?: string; // If provided, require sampling with this text
failAfterDelay?: number; // If set, task fails after this delay (ms)
cancelAfterDelay?: number; // If set, task cancels itself after this delay (ms)
/** If set, send params.task: { ttl } so the client creates a receiver task and returns { task } immediately */
receiverTaskTtl?: number;
}
/** Payload we receive from the client via tasks/result when using receiver-task mode */
interface ReceiverTaskPayload {
content: unknown;
isElicit?: boolean;
}
/**
* Poll the client for a receiver task until terminal, then fetch tasks/result.
* Used when the server sent a create (elicitation or sampling) with params.task and got { task } back.
*/
async function pollReceiverTaskPayload(
extra: CreateTaskRequestHandlerExtra,
clientTaskId: string,
resultSchema: z.ZodTypeAny,
isElicit: boolean,
): Promise<ReceiverTaskPayload | null> {
for (let i = 0; i < 50; i++) {
if (getTestServerControl()?.isClosing()) break;
const getRes = await extra.sendRequest(
{ method: "tasks/get", params: { taskId: clientTaskId } },
GetTaskResultSchema,
);
const status = (getRes as { status: string }).status;
if (
status === "completed" ||
status === "failed" ||
status === "cancelled"
) {
if (status === "completed") {
try {
const payload = await extra.sendRequest(
{ method: "tasks/result", params: { taskId: clientTaskId } },
resultSchema,
);
return {
content: (payload as { content?: unknown }).content,
isElicit: isElicit ? true : undefined,
};
} catch {
// tasks/result may fail if task failed
}
}
break;
}
await new Promise((r) => setTimeout(r, 100));
}
return null;
}
/** Params for the async task execution runner used by the task tool */
interface RunTaskExecutionParams {
task: { taskId: string };
extra: CreateTaskRequestHandlerExtra;
message?: string;
progressToken?: string | number;
options: TaskToolOptions;
}
/**
* Runs the task execution (input phase, progress, delay, fail/cancel, completion).
* Invoked fire-and-forget from createTask after creating the task.
*/
async function runTaskExecution(params: RunTaskExecutionParams): Promise<void> {
const { task, extra, message, progressToken, options } = params;
const {
delayMs = 1000,
progressUnits,
elicitationSchema,
samplingText,
failAfterDelay,
cancelAfterDelay,
receiverTaskTtl,
} = options;
let receiverTaskPayload: ReceiverTaskPayload | null = null;
try {
// --- Input phase: elicitation or sampling (optional receiver-task polling) ---
if (elicitationSchema) {
await extra.taskStore.updateTaskStatus(task.taskId, "input_required");
try {
const jsonSchema = z.toJSONSchema(
elicitationSchema,
) as ElicitRequestFormParams["requestedSchema"];
const elicitationParams: ElicitRequestFormParams = {
message: `Please provide input for task ${task.taskId}`,
requestedSchema: jsonSchema,
_meta: {
[RELATED_TASK_META_KEY]: { taskId: task.taskId },
},
...(receiverTaskTtl != null && { task: { ttl: receiverTaskTtl } }),
};
const elicitResponse = await extra.sendRequest(
{
method: "elicitation/create",
params: elicitationParams,
},
(receiverTaskTtl != null
? z.union([ElicitResultSchema, CreateTaskResultSchema])
: ElicitResultSchema) as typeof ElicitResultSchema,
);
// The union result may carry a `task` handle that `ElicitResult` doesn't
// model. That field is optional, so a single narrowing cast reaches it.
const elicitWithTask = elicitResponse as {
task?: { taskId: string };
};
if (receiverTaskTtl != null && elicitWithTask?.task) {
receiverTaskPayload =
(await pollReceiverTaskPayload(
extra,
elicitWithTask.task.taskId,
ElicitResultSchema,
true,
)) ?? null;
}
await extra.taskStore.updateTaskStatus(task.taskId, "working");
} catch (error) {
console.error("[flexibleTask] Elicitation error:", error);
await extra.taskStore.updateTaskStatus(
task.taskId,
"failed",
error instanceof Error ? error.message : String(error),
);
return;
}
}
if (samplingText) {
await extra.taskStore.updateTaskStatus(task.taskId, "input_required");
try {
const samplingResponse = await extra.sendRequest(
{
method: "sampling/createMessage",
params: {
messages: [
{
role: "user",
content: { type: "text", text: samplingText },
},
],
maxTokens: 100,
_meta: {
[RELATED_TASK_META_KEY]: { taskId: task.taskId },
},
...(receiverTaskTtl != null && {
task: { ttl: receiverTaskTtl },
}),
},
},
(receiverTaskTtl != null
? z.union([CreateMessageResultSchema, CreateTaskResultSchema])
: CreateMessageResultSchema) as typeof CreateMessageResultSchema,
);
// The union result may carry a `task` handle that `CreateMessageResult`
// doesn't model. That field is optional, so a single narrowing cast
// reaches it.
const samplingWithTask = samplingResponse as {
task?: { taskId: string };
};
if (receiverTaskTtl != null && samplingWithTask?.task) {
receiverTaskPayload =
(await pollReceiverTaskPayload(
extra,
samplingWithTask.task.taskId,
CreateMessageResultSchema,
false,
)) ?? null;
}
await extra.taskStore.updateTaskStatus(task.taskId, "working");
} catch (error) {
console.error("[flexibleTask] Sampling error:", error);
await extra.taskStore.updateTaskStatus(
task.taskId,
"failed",
error instanceof Error ? error.message : String(error),
);
return;
}
}
// --- Progress or delay ---
if (
progressUnits !== undefined &&
progressUnits > 0 &&
progressToken !== undefined
) {
for (let i = 1; i <= progressUnits; i++) {
if (getTestServerControl()?.isClosing()) break;
await new Promise((resolve) =>
setTimeout(resolve, delayMs / progressUnits),
);
if (getTestServerControl()?.isClosing()) break;
try {
await extra.sendNotification({
method: "notifications/progress",
params: {
progress: i,
total: progressUnits,
message: `Processing... ${i}/${progressUnits}`,
progressToken,
_meta: {
[RELATED_TASK_META_KEY]: { taskId: task.taskId },
},
},
});
} catch (error) {
console.error("[flexibleTask] Progress notification error:", error);
break;
}
}
} else {
await new Promise((resolve) => setTimeout(resolve, delayMs));
}
// --- Optional fail/cancel ---
if (failAfterDelay !== undefined) {
await new Promise((resolve) => setTimeout(resolve, failAfterDelay));
await extra.taskStore.updateTaskStatus(
task.taskId,
"failed",
"Task failed as configured",
);
return;
}
if (cancelAfterDelay !== undefined) {
await new Promise((resolve) => setTimeout(resolve, cancelAfterDelay));
await extra.taskStore.updateTaskStatus(task.taskId, "cancelled");
return;
}
// --- Complete with stored or default result ---
const result =
receiverTaskPayload?.content != null
? receiverTaskPayload.isElicit
? {
content: [
{
type: "text" as const,
text: JSON.stringify(receiverTaskPayload.content),
},
],
}
: {
content: Array.isArray(receiverTaskPayload.content)
? receiverTaskPayload.content
: [receiverTaskPayload.content],
}
: {
content: [
{
type: "text",
text: JSON.stringify({
message: `Task completed: ${message || "no message"}`,
taskId: task.taskId,
}),
},
],
};
await extra.taskStore.storeTaskResult(task.taskId, "completed", result);
await extra.taskStore.updateTaskStatus(task.taskId, "completed");
} catch (error) {
try {
const currentTask = await extra.taskStore.getTask(task.taskId);
if (
currentTask &&
currentTask.status !== "completed" &&
currentTask.status !== "failed" &&
currentTask.status !== "cancelled"
) {
await extra.taskStore.updateTaskStatus(
task.taskId,
"failed",
error instanceof Error ? error.message : String(error),
);
}
} catch (statusError) {
console.error(
"[flexibleTask] Error checking/updating task status:",
statusError,
);
}
}
}
/** Creates an immediate (non-task) tool that completes after a delay. */
export function createImmediateTool(
options: ImmediateToolOptions = {},
): ToolDefinition {
const { name = "flexibleTask", delayMs = 1000 } = options;
return {
name,
description: "A tool that completes immediately without creating a task",
inputSchema: {
message: z.string().optional().describe("Optional message parameter"),
},
handler: async (
params: Record<string, unknown>,
_context?: TestServerContext,
): Promise<CallToolResult> => {
await new Promise((resolve) => setTimeout(resolve, delayMs));
return toToolResult(
`Task completed immediately: ${params.message ?? "no message"}`,
);
},
};
}
/** Creates a task tool (createTask + getTask + getTaskResult) with optional progress, elicitation, sampling, etc. */
export function createTaskTool(
options: TaskToolOptions = {},
): TaskToolDefinition {
const { name = "flexibleTask", taskSupport = "required" } = options;
return {
name,
description: `A flexible task tool supporting progress, elicitation, and sampling`,
inputSchema: {
message: z.string().optional().describe("Optional message parameter"),
},
execution: {
taskSupport: taskSupport as "required" | "optional",
},
handler: {
createTask: async (args, extra) => {
const message = (args as Record<string, unknown>)?.message as
| string
| undefined;
const progressToken = extra._meta?.progressToken as
| string
| number
| undefined;
const task = await extra.taskStore.createTask({});
runTaskExecution({
task,
extra,
message,
progressToken,
options,
}).catch(() => {});
return { task };
},
getTask: async (
_args: ShapeOutput<{ message?: z.ZodString }>,
extra: TaskRequestHandlerExtra,
): Promise<GetTaskResult> => {
const task = await extra.taskStore.getTask(extra.taskId);
return task as GetTaskResult;
},
getTaskResult: async (
_args: ShapeOutput<{ message?: z.ZodString }>,
extra: TaskRequestHandlerExtra,
): Promise<CallToolResult> => {
const result = await extra.taskStore.getTaskResult(extra.taskId);
if (!result.content) {
throw new Error("Task result does not have content field");
}
return result as CallToolResult;
},
},
};
}
/**
* Create a simple task tool that completes after a delay
*/
export function createSimpleTaskTool(
name: string = "simple_task",
delayMs: number = 1000,
): TaskToolDefinition {
return createTaskTool({ name, delayMs });
}
/**
* Create a task tool that sends progress notifications
*/
export function createProgressTaskTool(
name: string = "progress_task",
delayMs: number = 2000,
progressUnits: number = 5,
): TaskToolDefinition {
return createTaskTool({ name, delayMs, progressUnits });
}
/**
* Create a task tool that requires elicitation input
*/
export function createElicitationTaskTool(
name: string = "elicitation_task",
elicitationSchema?: z.ZodTypeAny,
): TaskToolDefinition {
return createTaskTool({
name,
elicitationSchema:
elicitationSchema ||
z.object({
input: z.string().describe("User input required for task"),
}),
});
}
/**
* Create a task tool that requires sampling input
*/
export function createSamplingTaskTool(
name: string = "sampling_task",
samplingText?: string,
): TaskToolDefinition {
return createTaskTool({
name,
samplingText: samplingText || "Please provide a response for this task",
});
}
/**
* Create a task tool with optional task support
*/
export function createOptionalTaskTool(
name: string = "optional_task",
delayMs: number = 500,
): TaskToolDefinition {
return createTaskTool({ name, taskSupport: "optional", delayMs });
}
/**
* Create a tool that does not support tasks (completes immediately without creating a task)
*/
export function createForbiddenTaskTool(
name: string = "forbidden_task",
delayMs: number = 100,
): ToolDefinition {
return createImmediateTool({ name, delayMs });
}
/**
* Create a tool that returns immediately without creating a task
* (for testing callTool() with task-supporting server config where the tool itself is immediate)
*/
export function createImmediateReturnTaskTool(
name: string = "immediate_return_task",
delayMs: number = 100,
): ToolDefinition {
return createImmediateTool({ name, delayMs });
}
/**
* Get a server config with task support and task tools for testing
*/
export function getTaskServerConfig(): ServerConfig {
return {
serverInfo: createTestServerInfo("test-task-server", "1.0.0"),
tasks: {
list: true,
cancel: true,
},
tools: [
createSimpleTaskTool(),
createProgressTaskTool(),
createElicitationTaskTool(),
createSamplingTaskTool(),
createOptionalTaskTool(),
createForbiddenTaskTool(),
createImmediateReturnTaskTool(),
],
logging: true, // Required for notifications/message and progress
};
}
/**
* Get default server config with common test tools, prompts, and resources
*/
export function getDefaultServerConfig(): ServerConfig {
return {
serverInfo: createTestServerInfo("test-mcp-server", "1.0.0"),
tools: [
createEchoTool(),
createGetSumTool(),
createGetAnnotatedMessageTool(),
createGetTempTool(),
createGetTempExtraTool(),
createSendNotificationTool(),
createWriteToStderrTool(),
createMcpAppDemoTool(),
],
prompts: [createSimplePrompt(), createArgsPrompt()],
resources: [
createArchitectureResource(),
createTestCwdResource(),
createTestEnvResource(),
createTestArgvResource(),
createMcpAppDemoResource(),
],
resourceTemplates: [
createFileResourceTemplate(),
createUserResourceTemplate(),
],
logging: true, // Required for notifications/message
};
}
/**
* OAuth Test Fixtures
*/
/**
* Creates a test server configuration with OAuth enabled
*/
export function createOAuthTestServerConfig(options: {
requireAuth?: boolean;
scopesSupported?: string[];
staticClients?: Array<{
clientId: string;
clientSecret?: string;
redirectUris?: string[];
}>;
supportDCR?: boolean;
supportCIMD?: boolean;
tokenExpirationSeconds?: number;
supportRefreshTokens?: boolean;
}): Partial<ServerConfig> {
return {
oauth: {
enabled: true,
mode: "combined",
requireAuth: options.requireAuth ?? false,
scopesSupported: options.scopesSupported ?? ["mcp"],
staticClients: options.staticClients,
supportDCR: options.supportDCR ?? false,
supportCIMD: options.supportCIMD ?? false,
tokenExpirationSeconds: options.tokenExpirationSeconds ?? 3600,
supportRefreshTokens: options.supportRefreshTokens ?? true,
},
};
}
/**
* MCP resource server that delegates authorization to an external AS (EMA / XAA testing).
* Access tokens must be JWTs from that AS (signature + iss validated via JWKS).
*/
export function createExternalResourceOAuthTestServerConfig(options: {
authorizationServers: string[];
requireAuth?: boolean;
scopesSupported?: string[];
resource?: string;
accessTokenIssuers?: string[];
jwksUri?: string;
resourceAudience?: string;
}): Partial<ServerConfig> {
return {
oauth: {
enabled: true,
mode: "protected-resource",
authorizationServers: options.authorizationServers,
requireAuth: options.requireAuth ?? true,
scopesSupported: options.scopesSupported ?? ["mcp"],
resource: options.resource,
accessTokenIssuers: options.accessTokenIssuers,
jwksUri: options.jwksUri,
resourceAudience: options.resourceAudience,
},
};
}