Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 86 additions & 32 deletions test-servers/README.md

Large diffs are not rendered by default.

78 changes: 78 additions & 0 deletions test-servers/__tests__/composable-tool-preset.test.ts
Original file line number Diff line number Diff line change
@@ -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();
}
});
});
36 changes: 36 additions & 0 deletions test-servers/configs/composable-tool.json
Original file line number Diff line number Diff line change
@@ -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" }
}
2 changes: 1 addition & 1 deletion test-servers/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
8 changes: 6 additions & 2 deletions test-servers/src/composable-test-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -478,7 +479,10 @@ export function createMcpServer(config: ServerConfig): McpServer {
outputSchema: tool.outputSchema as AnySchema,
}),
},
async (args, extra) => {
async (
args: Record<string, unknown> | undefined,
extra: RequestHandlerExtra<ServerRequest, ServerNotification>,
) => {
const result = await tool.handler(
args as Record<string, unknown>,
context,
Expand Down
3 changes: 3 additions & 0 deletions test-servers/src/preset-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import {
createSimplePrompt,
createArgsPrompt,
createNumberedPrompts,
createComposableTool,
} from "./test-server-fixtures.js";

export type PresetType = "tool" | "resource" | "resourceTemplate" | "prompt";
Expand Down Expand Up @@ -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}`);
}
Expand Down
88 changes: 87 additions & 1 deletion test-servers/src/test-server-fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>,
label: string,
) {
let validate: ReturnType<AjvJsonSchemaValidator["getValidator"]>;
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<string, unknown>,
): 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<string, unknown>;
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<string, unknown>) => {
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",
Expand Down Expand Up @@ -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<string, unknown>)?.message as
| string
| undefined;
Expand Down