diff --git a/test-servers/README.md b/test-servers/README.md index e4320f5b2..00a14a53e 100644 --- a/test-servers/README.md +++ b/test-servers/README.md @@ -163,38 +163,91 @@ Format: JSON or YAML. Infer from extension (`.json`, `.yaml`, `.yml`), or use `- ### Preset Registry -| Preset Name | Type | Params | Notes | -| ------------------------------------------------------------------------------------------------ | ------------------ | ------------------------------- | ----------------------------------------------------------- | -| echo | tool | none | Echo tool | -| add | tool | none | Add two numbers | -| get_sum | tool | none | Alias for add | -| write_to_stderr | tool | none | Writes message to stderr | -| collect_sample | tool | none | Sends sampling request to client | -| list_roots | tool | none | Calls roots/list on client | -| collect_elicitation | tool | none | Sends form elicitation request | -| collect_url_elicitation | tool | none | Sends URL elicitation request | -| url_elicitation_form | tool | message? | Hosts form server, URL elicitation, returns submitted value | -| send_notification | tool | none | Sends notification to client | -| get_annotated_message | tool | none | Returns annotated message + optional image | -| add_resource, remove_resource, add_tool, remove_tool, add_prompt, remove_prompt, update_resource | tool | none | Dynamic list changes | -| send_progress | tool | name? | Sends progress notifications | -| numbered_tools | tool[] | count | Creates N echo-like tools | -| simple_task | taskTool | name?, delayMs? | Task that completes after delay | -| progress_task | taskTool | name?, delayMs?, progressUnits? | Task with progress | -| elicitation_task | taskTool | name? | Task requiring form elicitation | -| sampling_task | taskTool | name?, samplingText? | Task requiring sampling | -| optional_task | taskTool | name?, delayMs? | Task with optional task support | -| forbidden_task | tool | name?, delayMs? | Non-task tool (completes immediately) | -| immediate_return_task | tool | name?, delayMs? | Immediate return (no task) | -| architecture | resource | none | Static architecture doc | -| test_cwd, test_env, test_argv | resource | none | Expose process.cwd(), env, argv | -| numbered_resources | resource[] | count | N static resources | -| file | resourceTemplate | none | file:///{path} template | -| user | resourceTemplate | none | user://{userId} template | -| numbered_resource_templates | resourceTemplate[] | count | N templates | -| simple_prompt | prompt | none | Simple static prompt | -| args_prompt | prompt | none | Prompt with city, state args | -| numbered_prompts | prompt[] | count | N static prompts | +| Preset Name | Type | Params | Notes | +| ------------------------------------------------------------------------------------------------ | ------------------ | --------------------------------------------------- | ----------------------------------------------------------- | +| echo | tool | none | Echo tool | +| composable_tool | tool | name, description?, inputSchema, structuredContent? | [Custom Tools](#custom-tools) | +| add | tool | none | Add two numbers | +| get_sum | tool | none | Alias for add | +| write_to_stderr | tool | none | Writes message to stderr | +| collect_sample | tool | none | Sends sampling request to client | +| list_roots | tool | none | Calls roots/list on client | +| collect_elicitation | tool | none | Sends form elicitation request | +| collect_url_elicitation | tool | none | Sends URL elicitation request | +| url_elicitation_form | tool | message? | Hosts form server, URL elicitation, returns submitted value | +| send_notification | tool | none | Sends notification to client | +| get_annotated_message | tool | none | Returns annotated message + optional image | +| add_resource, remove_resource, add_tool, remove_tool, add_prompt, remove_prompt, update_resource | tool | none | Dynamic list changes | +| send_progress | tool | name? | Sends progress notifications | +| numbered_tools | tool[] | count | Creates N echo-like tools | +| simple_task | taskTool | name?, delayMs? | Task that completes after delay | +| progress_task | taskTool | name?, delayMs?, progressUnits? | Task with progress | +| elicitation_task | taskTool | name? | Task requiring form elicitation | +| sampling_task | taskTool | name?, samplingText? | Task requiring sampling | +| optional_task | taskTool | name?, delayMs? | Task with optional task support | +| forbidden_task | tool | name?, delayMs? | Non-task tool (completes immediately) | +| immediate_return_task | tool | name?, delayMs? | Immediate return (no task) | +| architecture | resource | none | Static architecture doc | +| test_cwd, test_env, test_argv | resource | none | Expose process.cwd(), env, argv | +| numbered_resources | resource[] | count | N static resources | +| file | resourceTemplate | none | file:///{path} template | +| user | resourceTemplate | none | user://{userId} template | +| numbered_resource_templates | resourceTemplate[] | count | N templates | +| simple_prompt | prompt | none | Simple static prompt | +| args_prompt | prompt | none | Prompt with city, state args | +| numbered_prompts | prompt[] | count | N static prompts | + +### Custom Tools + +**The `composable_tool`** preset allows you to define a custom tool whose **shape** you define in **`params`** (`name`, optional `description`, **`inputSchema`** as JSON Schema for the tool’s `arguments`). **Functionally** it **echoes validated input arguments**. Calls whose arguments do not match **`inputSchema`** are rejected. When **`structuredContent`** is **`true`**, **`tools/list`** includes **`outputSchema`** as well as **`inputSchema`**, with the **same logical shape**, because structured output is the same validated arguments object echoed back. + +**`params`:** + +| Field | Required | Default | Description | +| ------------------- | -------- | ------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `name` | yes | — | Tool name in `tools/call` (must be unique on the server). | +| `description` | no | omitted | Shown in `tools/list`. | +| `inputSchema` | yes | — | JSON Schema for the tool’s `arguments` object. If the schema is invalid, the server fails at startup. | +| `structuredContent` | no | `false` | **`false`:** `tools/call` result is **text** only (`JSON.stringify(arguments)`). **`true`:** same **text** plus **`structuredContent`** set to the parsed arguments object. | + +**In code:** `createComposableTool(params)` from `@modelcontextprotocol/inspector-test-server` with the same `params` as in config. + +**Example (config):** two tools—text echo and structured echo. Full file: [`configs/composable-tool.json`](configs/composable-tool.json). + +```json +{ + "serverInfo": { "name": "composable-tool-demo", "version": "1.0.0" }, + "tools": [ + { + "preset": "composable_tool", + "params": { + "name": "echo_json_schema", + "description": "Echo message as text", + "inputSchema": { + "type": "object", + "properties": { "msg": { "type": "string" } }, + "required": ["msg"] + }, + "structuredContent": false + } + }, + { + "preset": "composable_tool", + "params": { + "name": "echo_structured", + "description": "Echo number as structured content", + "inputSchema": { + "type": "object", + "properties": { "n": { "type": "number" } }, + "required": ["n"] + }, + "structuredContent": true + } + } + ], + "transport": { "type": "stdio" } +} +``` ### Transport and Client Config @@ -248,3 +301,4 @@ For SSE, use `"type": "sse"` and a URL ending in `/sse` (e.g. `http://localhost: See `configs/` for example configs: - **demo.json** — Minimal server with echo tool only (stdio). Use for smoke testing. +- **composable-tool.json** — [`composable_tool` / Custom Tools](#custom-tools): text and structured echo tools with JSON Schema args. diff --git a/test-servers/__tests__/composable-tool-preset.test.ts b/test-servers/__tests__/composable-tool-preset.test.ts new file mode 100644 index 000000000..c8d76147e --- /dev/null +++ b/test-servers/__tests__/composable-tool-preset.test.ts @@ -0,0 +1,78 @@ +/** + * composable_tool preset: JSON Schema validation + echo (text / structuredContent) + */ + +import { describe, it, expect } from "vitest"; +import path from "path"; +import { fileURLToPath } from "url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { resolvePreset } from "../src/preset-registry.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +describe("composable_tool preset", () => { + it("rejects invalid params at resolve time", () => { + expect(() => + resolvePreset("tool", "composable_tool", { + name: "", + inputSchema: { type: "object" }, + }), + ).toThrow("name"); + + expect(() => + resolvePreset("tool", "composable_tool", { + name: "x", + inputSchema: "not-an-object", + }), + ).toThrow("inputSchema"); + }); + + it("runs via stdio: text echo and structured echo, and rejects bad args", async () => { + const scriptPath = path.join(__dirname, "../build/server-composable.js"); + const configPath = path.join(__dirname, "../configs/composable-tool.json"); + const transport = new StdioClientTransport({ + command: "node", + args: [scriptPath, "--config", configPath], + cwd: path.join(__dirname, ".."), + }); + + const client = new Client( + { name: "composable-tool-test-client", version: "1.0.0" }, + { capabilities: {} }, + ); + + await client.connect(transport); + + try { + const { tools } = await client.listTools(); + expect(tools.some((t) => t.name === "echo_json_schema")).toBe(true); + expect(tools.some((t) => t.name === "echo_structured")).toBe(true); + + const textResult = await client.callTool({ + name: "echo_json_schema", + arguments: { msg: "hi" }, + }); + expect(textResult.isError).not.toBe(true); + const textPart = ( + textResult.content as { type: string; text?: string }[] + ).find((c) => c.type === "text"); + expect(textPart?.text).toBe(JSON.stringify({ msg: "hi" })); + + const bad = await client.callTool({ + name: "echo_json_schema", + arguments: {}, + }); + expect(bad.isError).toBe(true); + + const structResult = await client.callTool({ + name: "echo_structured", + arguments: { n: 42 }, + }); + expect(structResult.isError).not.toBe(true); + expect(structResult.structuredContent).toEqual({ n: 42 }); + } finally { + await transport.close(); + } + }); +}); diff --git a/test-servers/configs/composable-tool.json b/test-servers/configs/composable-tool.json new file mode 100644 index 000000000..06f8c9112 --- /dev/null +++ b/test-servers/configs/composable-tool.json @@ -0,0 +1,36 @@ +{ + "serverInfo": { "name": "composable-tool-test", "version": "1.0.0" }, + "tools": [ + { + "preset": "composable_tool", + "params": { + "name": "echo_json_schema", + "description": "Echo message as text", + "inputSchema": { + "type": "object", + "properties": { + "msg": { "type": "string" } + }, + "required": ["msg"] + }, + "structuredContent": false + } + }, + { + "preset": "composable_tool", + "params": { + "name": "echo_structured", + "description": "Echo number as structured content", + "inputSchema": { + "type": "object", + "properties": { + "n": { "type": "number" } + }, + "required": ["n"] + }, + "structuredContent": true + } + } + ], + "transport": { "type": "stdio" } +} diff --git a/test-servers/package.json b/test-servers/package.json index c7e643c9f..e037343fa 100644 --- a/test-servers/package.json +++ b/test-servers/package.json @@ -15,7 +15,7 @@ ], "scripts": { "build": "tsc", - "lint": "prettier --check . && eslint . --max-warnings 0 && echo 'Lint passed.'", + "lint": "prettier --check src __tests__ configs README.md package.json tsconfig.json && eslint . --max-warnings 0 && echo 'Lint passed.'", "test": "vitest run" }, "dependencies": { diff --git a/test-servers/src/composable-test-server.ts b/test-servers/src/composable-test-server.ts index 305ea4e59..3ecfff366 100644 --- a/test-servers/src/composable-test-server.ts +++ b/test-servers/src/composable-test-server.ts @@ -71,7 +71,8 @@ const EMPTY_OBJECT_JSON_SCHEMA = { properties: {}, } as const; -type ToolInputSchema = ZodRawShapeCompat; +/** Tool args: either a Zod raw shape or a single Zod schema (e.g. AJV-backed object schema). */ +type ToolInputSchema = ZodRawShapeCompat | AnySchema; type PromptArgsSchema = ZodRawShapeCompat; interface ServerState { @@ -478,7 +479,10 @@ export function createMcpServer(config: ServerConfig): McpServer { outputSchema: tool.outputSchema as AnySchema, }), }, - async (args, extra) => { + async ( + args: Record | undefined, + extra: RequestHandlerExtra, + ) => { const result = await tool.handler( args as Record, context, diff --git a/test-servers/src/preset-registry.ts b/test-servers/src/preset-registry.ts index 8ca515c5f..2779b796a 100644 --- a/test-servers/src/preset-registry.ts +++ b/test-servers/src/preset-registry.ts @@ -50,6 +50,7 @@ import { createSimplePrompt, createArgsPrompt, createNumberedPrompts, + createComposableTool, } from "./test-server-fixtures.js"; export type PresetType = "tool" | "resource" | "resourceTemplate" | "prompt"; @@ -150,6 +151,8 @@ function resolveToolPreset( get("name") as string | undefined, Number(get("delayMs")) || undefined, ); + case "composable_tool": + return createComposableTool(p); default: throw new Error(`Unknown tool preset: ${name}`); } diff --git a/test-servers/src/test-server-fixtures.ts b/test-servers/src/test-server-fixtures.ts index da22760d2..189d9f69b 100644 --- a/test-servers/src/test-server-fixtures.ts +++ b/test-servers/src/test-server-fixtures.ts @@ -42,6 +42,8 @@ import type { } from "@modelcontextprotocol/sdk/types.js"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; import type { ZodRawShapeCompat } from "@modelcontextprotocol/sdk/server/zod-compat.js"; +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv"; +import type { JsonSchemaType } from "@modelcontextprotocol/sdk/validation/types"; /** Build a CallToolResult from a text message (and optional isError). */ function toToolResult(text: string, isError?: boolean): CallToolResult { @@ -153,6 +155,87 @@ export function createNumberedPrompts(count: number): PromptDefinition[] { /** * Create an "echo" tool that echoes back the input message */ +/** + * Build a Zod object schema whose parse step validates `arguments` with the same + * JSON Schema stack as elicitation (`AjvJsonSchemaValidator`), so arbitrary MCP + * tool `inputSchema` shapes work while `McpServer.registerTool` stays satisfied. + */ +function zodObjectSchemaFromJsonToolSchema( + jsonSchema: Record, + label: string, +) { + let validate: ReturnType; + try { + const ajv = new AjvJsonSchemaValidator(); + validate = ajv.getValidator(jsonSchema as JsonSchemaType); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + throw new Error(`${label}: could not compile inputSchema: ${msg}`); + } + + return z.looseObject({}).superRefine((data, ctx) => { + const result = validate(data); + if (!result.valid) { + ctx.addIssue({ + code: "custom", + message: result.errorMessage ?? "Invalid tool arguments", + }); + } + }); +} + +/** + * Preset `composable_tool`: register a tool whose input is validated against + * `params.inputSchema` (JSON Schema) via the SDK AJV validator; the handler + * echoes parsed arguments as text or structured content. + */ +export function createComposableTool( + params: Record, +): ToolDefinition { + const name = params.name; + if (typeof name !== "string" || !name.trim()) { + throw new Error( + "composable_tool: params.name is required and must be a non-empty string", + ); + } + const description = + typeof params.description === "string" ? params.description : ""; + const raw = params.inputSchema; + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + throw new Error( + "composable_tool: params.inputSchema is required and must be a JSON object", + ); + } + const jsonSchema = raw as Record; + const structuredContent = params.structuredContent === true; + + const argsSchema = zodObjectSchemaFromJsonToolSchema( + jsonSchema, + "composable_tool", + ); + + return { + name: name.trim(), + description, + inputSchema: argsSchema, + ...(structuredContent ? { outputSchema: argsSchema } : {}), + handler: async (p: Record) => { + if (structuredContent) { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify(p), + }, + ], + structuredContent: p, + }; + } + return toToolResult(JSON.stringify(p)); + }, + }; +} + export function createEchoTool(): ToolDefinition { return { name: "echo", @@ -1691,7 +1774,10 @@ export function createTaskTool( taskSupport: taskSupport as "required" | "optional", }, handler: { - createTask: async (args, extra) => { + createTask: async ( + args: ShapeOutput<{ message?: z.ZodString }>, + extra: CreateTaskRequestHandlerExtra, + ) => { const message = (args as Record)?.message as | string | undefined;