* feat(apps): render running MCP Apps end-to-end (#1370) Wire the v2 Apps screen so clicking Open App actually runs an MCP App. Previously App.tsx stubbed the three load-bearing pieces (sandbox URL, bridge factory, action handlers), so the iframe loaded about:blank and nothing crossed the bridge. - Add useSandboxUrl hook (core/react) to read sandboxUrl from GET /api/config and thread it down as sandboxPath; render an "MCP Apps unavailable" state instead of a blank iframe when absent. - Add createAppBridgeFactory: builds a host-side ext-apps AppBridge over the active client's SDK client, reads the tool's UI resource on the sandbox handshake and pushes it into the proxy, handles openLinks, and connects a PostMessageTransport. - AppRenderer: pass the tool to the bridge factory; buffer tool input/result and release them once the view signals `initialized` (input before result, latest-wins). - Move the AppRenderer handle ref to App.tsx so onOpenApp issues tools/call with the form values and delivers input + result to the running app via the bridge; onCloseApp tears the bridge/iframe down. - Stories: themed sandbox placeholder (visible in light/dark), an echo-bridge running story, and an unavailable story. New unit tests for the factory, the buffering, useSandboxUrl, and the empty state. The listChanged indicator badge wiring is tracked separately in #1402. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * fix(apps): make MCP Apps render in dev — single bridge + result passthrough Two bugs kept apps (e.g. NightRider's MIDI Pattern Editor) stuck on an empty shell even though the host fetched the resource and called the tool correctly. 1. StrictMode double-mounted the bridge. The dev-only effect setup→cleanup→ setup built TWO host bridges/transports, double-loaded the sandbox, and raced the app's ui/initialize handshake (it timed out, so `initialized` never fired and buffered input/result were never flushed). AppRenderer now defers bridge disposal to a microtask and, when the effect re-runs synchronously with identical inputs, cancels the disposal and reuses the live bridge — so a single bridge survives the double-invoke. The pending input/result buffer is no longer cleared on a transient cleanup. 2. The SDK rejected the server's result. callTool() validates structuredContent against the tool's outputSchema and throws on a mismatch — but an MCP App is a passthrough: the host must forward whatever the server returned to the running view (which legacy hosts render fine). InspectorClient.callTool gains a `skipOutputValidation` option that routes through client.request() (no wrapper validation); onOpenApp uses it. Verified end-to-end against a real app via Playwright: the editor now renders its grid + pattern data identically to the v1 inspector. Tests: AppRenderer StrictMode single-bridge/no-dispose case; a get_temp_extra test-server fixture (returns an extra structuredContent property) with integration tests covering both the throwing default path and the skipOutputValidation passthrough. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(apps): warn (toast) when an app's result violates its outputSchema The skipOutputValidation passthrough keeps schema-mismatched apps rendering, but that silently hid a real spec problem from server developers. Now the host still renders, but surfaces the mismatch. - InspectorClient.callTool runs the SDK's own Ajv output validation on the skipOutputValidation path NON-fatally and returns the message as `outputValidationError` on the ToolCallInvocation (and the toolCallResultChange event); the result is still delivered. - App.tsx onOpenApp shows a yellow toast when outputValidationError is set, explaining strict MCP clients may not render the app. Tests: integration cases asserting outputValidationError is set for a schema-violating result (get_temp_extra) and unset for a valid one (get_temp). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * feat(apps): summarize output-schema warning in toast + details modal The previous toast dumped the full (very long) validation error and overflowed. Now the toast shows a one-line summary with a "View validation details" link that opens a modal containing the full error in a read-only, scrollable textarea. - New OutputValidationModal group component (+ story + tests). - App.tsx: toast message is a summary + link; clicking it opens the modal populated with the tool name and the full outputValidationError. Verified via Playwright against the NightRider MIDI editor: toast is concise, link opens the modal with the scrollable details. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * refactor(apps): address PR review — theme stability, onError, validation polish Responds to the automated PR review on #1403. - Theme toggle no longer rebuilds a running app's bridge. createAppBridgeFactory reads the resolved theme from the DOM (Mantine's data-mantine-color-scheme) at bridge-build time instead of capturing isDark in the factory deps, so the factory identity is stable across theme flips. (#1) - Wire AppRenderer's onError through AppsScreen -> InspectorView -> App.tsx, surfacing bridge/runtime failures (no client, late rejection, failed tools/call) as a red toast instead of a silent blank iframe. (#2) - Extract validateToolOutput into a pure, unit-tested helper covering the matching / extra-property / missing-structuredContent / error / bad-schema cases (the no-structuredContent branch can't be produced via the SDK server, which rejects it before sending). (#4) - useSandboxUrl aborts its in-flight fetch on unmount (cancelled flag) to avoid setState after unmount. (#5) - Schema-mismatch toast: autoClose:false (details modal is one click away). (note) - AppRenderer: add a state-machine comment block documenting the bridge lifecycle refs. (#6) - inspectorClient: note why the `as CallToolResult` casts are safe. (note) Tests: AppsScreen onError surface; validateToolOutput unit suite; factory theme read from DOM. validate + test:storybook green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * docs(apps): clarify theme attribute + useSandboxUrl fetch comments Re-review follow-ups (non-blocking): - createAppBridgeFactory: note that Mantine's data-mantine-color-scheme is only ever light/dark (auto is resolved before paint), so no `auto` branch is needed and matchMedia only covers an absent attribute. - useSandboxUrl: correct the doc to say it fetches on mount AND re-fetches on baseUrl/authToken change (idempotent), not "once on mount". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1934 lines
57 KiB
TypeScript
1934 lines
57 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 type { Implementation } from "@modelcontextprotocol/sdk/types.js";
|
|
import {
|
|
CreateMessageResultSchema,
|
|
CreateTaskResultSchema,
|
|
ElicitResultSchema,
|
|
GetTaskResultSchema,
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
import type {
|
|
ToolDefinition,
|
|
TaskToolDefinition,
|
|
ResourceDefinition,
|
|
PromptDefinition,
|
|
ResourceTemplateDefinition,
|
|
ServerConfig,
|
|
TestServerContext,
|
|
} from "./composable-test-server.js";
|
|
import { getTestServerControl } from "./test-server-control.js";
|
|
import type {
|
|
ElicitRequestFormParams,
|
|
ElicitRequestURLParams,
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
import type {
|
|
TaskRequestHandlerExtra,
|
|
CreateTaskRequestHandlerExtra,
|
|
} from "@modelcontextprotocol/sdk/experimental/tasks/interfaces.js";
|
|
import { RELATED_TASK_META_KEY } from "@modelcontextprotocol/sdk/types.js";
|
|
import { toJsonSchemaCompat } from "@modelcontextprotocol/sdk/server/zod-json-schema-compat.js";
|
|
import type { ShapeOutput } from "@modelcontextprotocol/sdk/server/zod-compat.js";
|
|
import type {
|
|
GetTaskResult,
|
|
CallToolResult,
|
|
ServerRequest,
|
|
ServerNotification,
|
|
} from "@modelcontextprotocol/sdk/types.js";
|
|
import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js";
|
|
import type { ZodRawShapeCompat } from "@modelcontextprotocol/sdk/server/zod-compat.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 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_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 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,
|
|
): Promise<CallToolResult> => {
|
|
if (!context) {
|
|
throw new Error("Server context not available");
|
|
}
|
|
const server = context.server;
|
|
|
|
const message = params.message as string;
|
|
const level = (params.level as string) || "info";
|
|
|
|
// Send a notification from the server
|
|
// Notifications don't have an id and use the jsonrpc format
|
|
try {
|
|
await 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 },
|
|
};
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* 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 ZodRawShapeCompat | 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 ZodRawShapeCompat | 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?: RequestHandlerExtra<ServerRequest, ServerNotification>,
|
|
): 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;
|
|
|
|
// 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 = toJsonSchemaCompat(
|
|
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,
|
|
);
|
|
const elicitWithTask = elicitResponse as unknown 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,
|
|
);
|
|
const samplingWithTask = samplingResponse as unknown 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;
|
|
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(),
|
|
],
|
|
prompts: [createSimplePrompt(), createArgsPrompt()],
|
|
resources: [
|
|
createArchitectureResource(),
|
|
createTestCwdResource(),
|
|
createTestEnvResource(),
|
|
createTestArgvResource(),
|
|
],
|
|
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,
|
|
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,
|
|
},
|
|
};
|
|
}
|