diff --git a/AGENTS.md b/AGENTS.md index 4501e3c94..cb2e9e093 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,6 +80,7 @@ src/ │ ├── proxy.ts # proxy, proxyRequest, fetchWithEvent │ ├── ws.ts # defineWebSocketHandler, defineWebSocket │ ├── json-rpc.ts # defineJsonRpcHandler, defineJsonRpcWebSocketHandler +│ ├── mcp.ts # defineMcpHandler, defineMcpTool, defineMcpResource, defineMcpPrompt │ ├── event-stream.ts # createEventStream (SSE) │ ├── static.ts # serveStatic │ ├── cache.ts # handleCacheHeaders @@ -89,6 +90,7 @@ src/ │ └── internal/ # Internal helpers (not exported) │ ├── auth.ts, body.ts, cors.ts, encoding.ts, ... │ ├── iron-crypto.ts # Session sealing crypto +│ ├── mcp.ts # MCP internal handler logic (handleMcpRequest, resolveMcpOptions) │ ├── standard-schema.ts # Standard schema validation │ └── validate.ts ├── _entries/ # Platform-specific entry points @@ -226,6 +228,30 @@ h3/tracing → Tracing plugin | `srvx` | Server abstraction (multi-runtime) | | `crossws` | WebSocket abstraction (optional peer dep) | +## MCP (Model Context Protocol) + +h3 implements MCP as a built-in utility — no SDK dependency. Wire format is JSON-RPC 2.0 over HTTP. Protocol version: `"2025-06-18"` (also accepts `"2025-03-26"`). + +### Architecture + +- **Public API** (`src/utils/mcp.ts`): Types + `defineMcpHandler`, `defineMcpTool`, `defineMcpResource`, `defineMcpPrompt` +- **Internal handler** (`src/utils/internal/mcp.ts`): `handleMcpRequest` processes HTTP → JSON-RPC, `resolveMcpOptions` handles lazy resolution +- Built on top of `src/utils/json-rpc.ts` (`processJsonRpcBody`, `createMethodMap`) + +### Key patterns + +- **`MaybeLazy`**: `tools`, `resources`, `prompts` accept `T | (() => T | Promise)`. Lazy values are resolved once and cached via `_resolveLazy()`. For static handler options, caching persists across requests. For dynamic `(event) => options`, each request gets fresh resolution. +- **`McpResolvedOptions`**: Internal type with pre-bound lazy resolvers. Created by `resolveMcpOptions()` and passed to `handleMcpRequest()`. +- `defineMcpHandler` supports both static options and `(event: H3Event) => McpHandlerOptions` for per-request config. + +### MCP methods implemented + +`initialize`, `ping`, `notifications/initialized`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `prompts/list`, `prompts/get` + +### Tests + +`test/mcp.test.ts` — unit tests for define helpers + integration tests via `describeMatrix` (web + node). + ## Best Practices for Contributing - Prefer web standard APIs over runtime-specific ones diff --git a/docs/2.utils/6.mcp.md b/docs/2.utils/6.mcp.md index 2a73de639..5c526be3a 100644 --- a/docs/2.utils/6.mcp.md +++ b/docs/2.utils/6.mcp.md @@ -6,8 +6,143 @@ icon: material-symbols:swap-calls > H3 MCP related utils. + + +### `defineMcpHandler(options)` + +Define an H3 event handler that implements the Model Context Protocol (MCP) over HTTP using JSON-RPC 2.0 as the wire format. + +Supports MCP methods: `initialize`, `ping`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `prompts/list`, `prompts/get`, and `notifications/initialized`. + +**Example:** + +```ts +app.all( + "/mcp", + defineMcpHandler({ + name: "my-server", + version: "1.0.0", + tools: [echoTool], + resources: [readmeResource], + prompts: [greetPrompt], + }), +); +``` + +**Example:** + +```ts +// Dynamic options based on request context +app.all( + "/mcp", + defineMcpHandler((event) => ({ + name: "my-server", + version: "1.0.0", + tools: getToolsForUser(event), + })), +); +``` + +### `defineMcpPrompt(definition)` + +Define an MCP prompt with optional arguments and a handler that returns messages. + +**Example:** + +```ts +// Prompt with arguments +const greetPrompt = defineMcpPrompt({ + name: "greet", + description: "Generate a greeting", + args: [{ name: "name", required: true }], + handler: async (args, event) => ({ + messages: [ + { role: "user", content: { type: "text", text: `Hello ${args.name}!` } }, + ], + }), +}); +``` + +**Example:** + +```ts +// Prompt without arguments +const helpPrompt = defineMcpPrompt({ + name: "help", + description: "Show help information", + handler: async (event) => ({ + messages: [ + { role: "user", content: { type: "text", text: "How can I help?" } }, + ], + }), +}); +``` + +### `defineMcpResource(definition)` + +Define an MCP resource with a static URI and a handler that returns its contents. + +**Example:** + +```ts +const readmeResource = defineMcpResource({ + name: "readme", + uri: "file:///readme", + description: "Project README", + mimeType: "text/markdown", + handler: async (uri, event) => ({ + contents: [{ uri: uri.toString(), text: "# My Project" }], + }), +}); +``` + +### `defineMcpTool(definition)` + +Define an MCP tool with a name, optional JSON Schema input, and a handler function. + +**Example:** + +```ts +// Tool with input parameters +const echoTool = defineMcpTool({ + name: "echo", + description: "Echo back a message", + inputSchema: { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, + handler: async (args, event) => ({ + content: [{ type: "text", text: args.message as string }], + }), +}); +``` + +**Example:** + +```ts +// Tool without input parameters +const pingTool = defineMcpTool({ + name: "ping", + description: "Returns pong", + handler: async (event) => ({ + content: [{ type: "text", text: "pong" }], + }), +}); +``` + + + +### `createJsonRpcError(id, code, message, data?)` + +Creates a JSON-RPC error response object. + +### `createMethodMap(methods)` + +Build a null-prototype lookup map to prevent prototype pollution. This ensures that method names like "__proto__", "constructor", "toString", "hasOwnProperty", etc. cannot resolve to inherited Object.prototype properties. + ### `defineJsonRpcHandler()` Creates an H3 event handler that implements the JSON-RPC 2.0 specification. @@ -76,4 +211,8 @@ app.get( ); ``` +### `processJsonRpcBody(body, methodMap, context)` + +Validates and processes a parsed JSON-RPC body (single or batch). + diff --git a/examples/mcp.mjs b/examples/mcp.mjs new file mode 100644 index 000000000..0fecdc55c --- /dev/null +++ b/examples/mcp.mjs @@ -0,0 +1,101 @@ +import { z } from "zod"; +import { H3, serve, defineMcpHandler, defineMcpTool, defineMcpResource, defineMcpPrompt } from "h3"; + +export const app = new H3(); + +// --- Define MCP tools --- + +const echoTool = defineMcpTool({ + name: "echo", + description: "Echo back a message", + inputSchema: { message: z.string().describe("The message to echo") }, + handler: async ({ message }) => ({ + content: [{ type: "text", text: message }], + }), +}); + +const calculatorTool = defineMcpTool({ + name: "calculator", + description: "Perform basic math operations", + inputSchema: { + operation: z.enum(["add", "subtract", "multiply", "divide"]), + a: z.number().describe("First number"), + b: z.number().describe("Second number"), + }, + handler: async ({ operation, a, b }) => { + let result; + switch (operation) { + case "add": + result = a + b; + break; + case "subtract": + result = a - b; + break; + case "multiply": + result = a * b; + break; + case "divide": + result = b !== 0 ? a / b : "Error: Division by zero"; + break; + } + return { + content: [{ type: "text", text: JSON.stringify({ operation, a, b, result }, null, 2) }], + }; + }, +}); + +// --- Define MCP resources --- + +const aboutResource = defineMcpResource({ + name: "about", + uri: "file:///about", + description: "Information about this MCP server", + handler: async (uri) => ({ + contents: [ + { + uri: uri.toString(), + text: "This is an example MCP server built with h3.", + }, + ], + }), +}); + +// --- Define MCP prompts --- + +const summarizePrompt = defineMcpPrompt({ + name: "summarize", + description: "Generate a prompt to summarize text", + argsSchema: { + text: z.string().describe("The text to summarize"), + }, + handler: async ({ text }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Please summarize the following text:\n\n${text}`, + }, + }, + ], + }), +}); + +// --- Create the MCP handler --- + +app.all( + "/mcp", + defineMcpHandler({ + name: "h3-mcp-example", + version: "1.0.0", + tools: [echoTool, calculatorTool], + resources: [aboutResource], + prompts: [summarizePrompt], + }), +); + +// --- Landing page --- + +app.get("/", () => "MCP server running at /mcp"); + +serve(app); diff --git a/src/index.ts b/src/index.ts index 827dedddd..a7205cbd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -211,6 +211,39 @@ export type { JsonRpcWebSocketMethod, } from "./utils/json-rpc.ts"; +// MCP + +export { + type McpTextContent, + type McpImageContent, + type McpAudioContent, + type McpResourceLink, + type McpEmbeddedResource, + type McpContentBlock, + type McpAnnotations, + type McpTextResourceContents, + type McpBlobResourceContents, + type McpResourceContents, + type McpCallToolResult, + type McpReadResourceResult, + type McpPromptMessage, + type McpGetPromptResult, + type McpToolAnnotations, + type McpToolCallback, + type McpToolDefinition, + type McpResourceCallback, + type McpResourceDefinition, + type McpPromptCallback, + type McpPromptArgument, + type McpPromptDefinition, + type MaybeLazy, + type McpHandlerOptions, + defineMcpHandler, + defineMcpTool, + defineMcpResource, + defineMcpPrompt, +} from "./utils/mcp.ts"; + // ---- Deprecated ---- export * from "./_deprecated.ts"; diff --git a/src/utils/internal/mcp.ts b/src/utils/internal/mcp.ts new file mode 100644 index 000000000..8d1709128 --- /dev/null +++ b/src/utils/internal/mcp.ts @@ -0,0 +1,250 @@ +import type { H3Event } from "../../event.ts"; +import type { JsonRpcMethod, JsonRpcRequest } from "../json-rpc.ts"; +import type { MaybeLazy, McpHandlerOptions } from "../mcp.ts"; +import type { McpToolDefinition, McpResourceDefinition, McpPromptDefinition } from "../mcp.ts"; +import { processJsonRpcBody, createJsonRpcError, createMethodMap } from "../json-rpc.ts"; +import { HTTPError } from "../../error.ts"; + +const MCP_PROTOCOL_VERSION = "2025-06-18"; +const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26"]); + +export interface McpResolvedOptions { + name: string; + version: string; + title?: string; + instructions?: string; + tools: () => Promise[] | undefined>; + resources: () => Promise; + prompts: () => Promise; +} + +export function resolveMcpOptions(options: McpHandlerOptions): McpResolvedOptions { + return { + name: options.name, + version: options.version, + title: options.title, + instructions: options.instructions, + tools: _resolveLazyArray(options.tools), + resources: _resolveLazyArray(options.resources), + prompts: _resolveLazyArray(options.prompts), + }; +} + +export async function handleMcpRequest( + options: McpResolvedOptions, + event: H3Event, +): Promise { + const method = event.req.method; + + if (method === "DELETE") { + return new Response(null, { status: 200 }); + } + + if (method !== "POST") { + return new Response("Method not allowed", { + status: 405, + headers: { allow: "POST, DELETE" }, + }); + } + + const protocolVersion = event.req.headers.get("mcp-protocol-version"); + if (protocolVersion && !SUPPORTED_PROTOCOL_VERSIONS.has(protocolVersion)) { + return new Response(`Unsupported MCP protocol version: ${protocolVersion}`, { + status: 400, + }); + } + + const methods = buildMcpMethodMap(options); + const methodMap = createMethodMap(methods); + + let body: unknown; + try { + body = await event.req.json(); + } catch { + return new Response(JSON.stringify(createJsonRpcError(null, -32_700, "Parse error")), { + status: 200, + headers: { "content-type": "application/json" }, + }); + } + + const result = await processJsonRpcBody(body, methodMap, event); + + if (result === undefined) { + return new Response(null, { status: 202 }); + } + + return new Response(JSON.stringify(result), { + status: 200, + headers: { "content-type": "application/json" }, + }); +} + +// --- Internal helpers --- + +function _resolveLazyArray(items: MaybeLazy[] | undefined): () => Promise { + if (!items?.length) { + return () => Promise.resolve(undefined); + } + let cached: Promise | undefined; + return () => { + if (!cached) { + cached = Promise.all( + items.map((item) => (typeof item === "function" ? (item as () => T | Promise)() : item)), + ); + } + return cached; + }; +} + +function buildMcpMethodMap(options: McpResolvedOptions): Record { + const methods: Record = {}; + + // initialize + methods["initialize"] = async () => { + const capabilities: Record = {}; + const [tools, resources, prompts] = await Promise.all([ + options.tools(), + options.resources(), + options.prompts(), + ]); + if (tools?.length) { + capabilities.tools = {}; + } + if (resources?.length) { + capabilities.resources = {}; + } + if (prompts?.length) { + capabilities.prompts = {}; + } + const serverInfo: Record = { + name: options.name, + version: options.version, + }; + if (options.title !== undefined) { + serverInfo.title = options.title; + } + const result: Record = { + protocolVersion: MCP_PROTOCOL_VERSION, + serverInfo, + capabilities, + }; + if (options.instructions !== undefined) { + result.instructions = options.instructions; + } + return result; + }; + + // ping + methods["ping"] = () => ({}); + + // notifications/initialized (no-op, handled as notification by JSON-RPC layer) + methods["notifications/initialized"] = () => undefined; + + // tools + methods["tools/list"] = async () => { + const tools = await options.tools(); + return { + tools: (tools ?? []).map((tool) => { + const entry: Record = { + name: tool.name, + inputSchema: tool.inputSchema ?? { type: "object" }, + }; + if (tool.title !== undefined) entry.title = tool.title; + if (tool.description !== undefined) entry.description = tool.description; + if (tool.outputSchema !== undefined) entry.outputSchema = tool.outputSchema; + if (tool.annotations !== undefined) entry.annotations = tool.annotations; + return entry; + }), + }; + }; + + methods["tools/call"] = async (req: JsonRpcRequest, event: H3Event) => { + const tools = await options.tools(); + const params = req.params as Record | undefined; + const name = params?.name as string; + const args = (params?.arguments ?? {}) as Record; + + const tool = tools?.find((t) => t.name === name); + if (!tool) { + throw new HTTPError({ status: 404, message: `Tool not found: ${name}` }); + } + + if (tool.inputSchema) { + return await (tool.handler as (args: Record, event: H3Event) => unknown)( + args, + event, + ); + } + return await (tool.handler as (event: H3Event) => unknown)(event); + }; + + // resources + methods["resources/list"] = async () => { + const resources = await options.resources(); + return { + resources: (resources ?? []).map((r) => { + const entry: Record = { + name: r.name, + uri: r.uri, + }; + if (r.title !== undefined) entry.title = r.title; + if (r.description !== undefined) entry.description = r.description; + if (r.mimeType !== undefined) entry.mimeType = r.mimeType; + if (r.size !== undefined) entry.size = r.size; + return entry; + }), + }; + }; + + methods["resources/read"] = async (req: JsonRpcRequest, event: H3Event) => { + const resources = await options.resources(); + const params = req.params as Record | undefined; + const uriStr = params?.uri as string; + const uri = new URL(uriStr); + + const resource = resources?.find((r) => r.uri === uri.toString()); + if (!resource) { + throw new HTTPError({ status: 404, message: `Resource not found: ${uriStr}` }); + } + + return await resource.handler(uri, event); + }; + + // prompts + methods["prompts/list"] = async () => { + const prompts = await options.prompts(); + return { + prompts: (prompts ?? []).map((p) => { + const entry: Record = { + name: p.name, + }; + if (p.title !== undefined) entry.title = p.title; + if (p.description !== undefined) entry.description = p.description; + if (p.args?.length) entry.arguments = p.args; + return entry; + }), + }; + }; + + methods["prompts/get"] = async (req: JsonRpcRequest, event: H3Event) => { + const prompts = await options.prompts(); + const params = req.params as Record | undefined; + const name = params?.name as string; + const args = (params?.arguments ?? {}) as Record; + + const prompt = prompts?.find((p) => p.name === name); + if (!prompt) { + throw new HTTPError({ status: 404, message: `Prompt not found: ${name}` }); + } + + if (prompt.args?.length) { + return await (prompt.handler as (args: Record, event: H3Event) => unknown)( + args, + event, + ); + } + return await (prompt.handler as (event: H3Event) => unknown)(event); + }; + + return methods; +} diff --git a/src/utils/json-rpc.ts b/src/utils/json-rpc.ts index a72654482..2847d6381 100644 --- a/src/utils/json-rpc.ts +++ b/src/utils/json-rpc.ts @@ -184,7 +184,7 @@ export function defineJsonRpcWebSocketHandler(opts: { * This ensures that method names like "__proto__", "constructor", "toString", * "hasOwnProperty", etc. cannot resolve to inherited Object.prototype properties. */ -function createMethodMap( +export function createMethodMap( methods: Record, ): Record { const methodMap: Record = Object.create(null); @@ -199,7 +199,7 @@ function createMethodMap( * * @returns The JSON-RPC response(s) to send, or `undefined` if all requests were notifications. */ -async function processJsonRpcBody( +export async function processJsonRpcBody( body: unknown, methodMap: Record unknown | Promise>, context: C, @@ -396,7 +396,7 @@ function isValidId(id: unknown): id is string | number | null { /** * Creates a JSON-RPC error response object. */ -const createJsonRpcError = ( +export const createJsonRpcError = ( id: string | number | null, code: number, message: string, diff --git a/src/utils/mcp.ts b/src/utils/mcp.ts new file mode 100644 index 000000000..d6d31dc12 --- /dev/null +++ b/src/utils/mcp.ts @@ -0,0 +1,430 @@ +import { defineHandler } from "../handler.ts"; +import { handleMcpRequest, resolveMcpOptions } from "./internal/mcp.ts"; + +import type { H3Event } from "../event.ts"; +import type { EventHandler } from "../types/handler.ts"; + +// --- MCP content types --- + +/** + * MCP text content block. + */ +export interface McpTextContent { + type: "text"; + text: string; + annotations?: McpAnnotations; +} + +/** + * MCP image content block. + */ +export interface McpImageContent { + type: "image"; + data: string; + mimeType: string; + annotations?: McpAnnotations; +} + +/** + * MCP audio content block. + */ +export interface McpAudioContent { + type: "audio"; + data: string; + mimeType: string; + annotations?: McpAnnotations; +} + +/** + * MCP resource link content block. + */ +export interface McpResourceLink { + type: "resource_link"; + uri: string; + name?: string; + description?: string; + mimeType?: string; + annotations?: McpAnnotations; +} + +/** + * MCP embedded resource content block. + */ +export interface McpEmbeddedResource { + type: "resource"; + resource: McpResourceContents; + annotations?: McpAnnotations; +} + +/** + * MCP content block union. + */ +export type McpContentBlock = + | McpTextContent + | McpImageContent + | McpAudioContent + | McpResourceLink + | McpEmbeddedResource; + +/** + * MCP annotations for content and resources. + */ +export interface McpAnnotations { + audience?: ("user" | "assistant")[]; + priority?: number; + lastModified?: string; +} + +// --- MCP resource types --- + +/** + * MCP text resource contents. + */ +export interface McpTextResourceContents { + uri: string; + mimeType?: string; + text: string; +} + +/** + * MCP blob resource contents. + */ +export interface McpBlobResourceContents { + uri: string; + mimeType?: string; + blob: string; +} + +/** + * MCP resource contents union. + */ +export type McpResourceContents = McpTextResourceContents | McpBlobResourceContents; + +// --- MCP result types --- + +/** + * Result of executing an MCP tool. + */ +export interface McpCallToolResult { + content: McpContentBlock[]; + structuredContent?: Record; + isError?: boolean; +} + +/** + * Result of reading an MCP resource. + */ +export interface McpReadResourceResult { + contents: McpResourceContents[]; +} + +/** + * MCP prompt message. + */ +export interface McpPromptMessage { + role: "user" | "assistant"; + content: McpContentBlock; +} + +/** + * Result of getting an MCP prompt. + */ +export interface McpGetPromptResult { + description?: string; + messages: McpPromptMessage[]; +} + +// --- MCP tool types --- + +/** + * MCP tool annotations describing behavior hints. + */ +export interface McpToolAnnotations { + title?: string; + readOnlyHint?: boolean; + destructiveHint?: boolean; + idempotentHint?: boolean; + openWorldHint?: boolean; +} + +/** + * A function that handles an MCP tool call. + * + * When `InputSchema` is provided, receives parsed arguments as the first parameter. + * Always receives the `H3Event` for request context access. + */ +export type McpToolCallback | undefined = undefined> = + InputSchema extends Record + ? ( + args: Record, + event: H3Event, + ) => McpCallToolResult | Promise + : (event: H3Event) => McpCallToolResult | Promise; + +/** + * MCP tool definition. + * + * The `inputSchema` should be a JSON Schema object describing the tool's input parameters. + */ +export interface McpToolDefinition< + InputSchema extends Record | undefined = undefined, +> { + name: string; + title?: string; + description?: string; + inputSchema?: InputSchema; + outputSchema?: Record; + annotations?: McpToolAnnotations; + handler: McpToolCallback; +} + +// --- MCP resource types --- + +/** + * A function that handles reading an MCP resource. + */ +export type McpResourceCallback = ( + uri: URL, + event: H3Event, +) => McpReadResourceResult | Promise; + +/** + * MCP resource definition. + */ +export interface McpResourceDefinition { + name: string; + title?: string; + description?: string; + uri: string; + mimeType?: string; + size?: number; + handler: McpResourceCallback; +} + +// --- MCP prompt types --- + +/** + * A function that handles an MCP prompt without arguments. + */ +export type McpPromptCallbackWithoutArgs = ( + event: H3Event, +) => McpGetPromptResult | Promise; + +/** + * A function that handles an MCP prompt with arguments. + */ +export type McpPromptCallbackWithArgs = ( + args: Record, + event: H3Event, +) => McpGetPromptResult | Promise; + +/** + * A function that handles an MCP prompt. + */ +export type McpPromptCallback = McpPromptCallbackWithoutArgs | McpPromptCallbackWithArgs; + +/** + * MCP prompt argument definition (for `prompts/list` response). + */ +export interface McpPromptArgument { + name: string; + description?: string; + required?: boolean; +} + +/** + * MCP prompt definition with arguments. + */ +export interface McpPromptDefinitionWithArgs { + name: string; + title?: string; + description?: string; + args: McpPromptArgument[]; + handler: McpPromptCallbackWithArgs; +} + +/** + * MCP prompt definition without arguments. + */ +export interface McpPromptDefinitionWithoutArgs { + name: string; + title?: string; + description?: string; + args?: undefined; + handler: McpPromptCallbackWithoutArgs; +} + +/** + * MCP prompt definition. + */ +export type McpPromptDefinition = McpPromptDefinitionWithArgs | McpPromptDefinitionWithoutArgs; + +// --- handler options --- + +/** + * A value that can be provided directly or as a lazy function that returns + * a (possibly async) value. Lazy values are resolved once and cached. + */ +export type MaybeLazy = T | (() => T | Promise); + +/** + * Options for `defineMcpHandler`. + */ +export interface McpHandlerOptions { + name: string; + version: string; + title?: string; + instructions?: string; + tools?: MaybeLazy>[]; + resources?: MaybeLazy[]; + prompts?: MaybeLazy[]; +} + +// --- definition helpers --- + +/** + * Define an MCP tool with a name, optional JSON Schema input, and a handler function. + * + * @param definition - The tool definition including name, description, input schema, and handler. + * @returns The same definition (identity function for type inference). + * + * @example + * // Tool with input parameters + * const echoTool = defineMcpTool({ + * name: "echo", + * description: "Echo back a message", + * inputSchema: { + * type: "object", + * properties: { message: { type: "string" } }, + * required: ["message"], + * }, + * handler: async (args, event) => ({ + * content: [{ type: "text", text: args.message as string }], + * }), + * }); + * + * @example + * // Tool without input parameters + * const pingTool = defineMcpTool({ + * name: "ping", + * description: "Returns pong", + * handler: async (event) => ({ + * content: [{ type: "text", text: "pong" }], + * }), + * }); + * + * @see https://modelcontextprotocol.io/specification/2025-06-18/server/tools + */ +export function defineMcpTool< + const InputSchema extends Record | undefined = undefined, +>(definition: McpToolDefinition): McpToolDefinition { + return definition; +} + +/** + * Define an MCP resource with a static URI and a handler that returns its contents. + * + * @param definition - The resource definition including name, URI, and handler. + * @returns The same definition (identity function for type inference). + * + * @example + * const readmeResource = defineMcpResource({ + * name: "readme", + * uri: "file:///readme", + * description: "Project README", + * mimeType: "text/markdown", + * handler: async (uri, event) => ({ + * contents: [{ uri: uri.toString(), text: "# My Project" }], + * }), + * }); + * + * @see https://modelcontextprotocol.io/specification/2025-06-18/server/resources + */ +export function defineMcpResource(definition: McpResourceDefinition): McpResourceDefinition { + return definition; +} + +/** + * Define an MCP prompt with optional arguments and a handler that returns messages. + * + * @param definition - The prompt definition including name, argument definitions, and handler. + * @returns The same definition (identity function for type inference). + * + * @example + * // Prompt with arguments + * const greetPrompt = defineMcpPrompt({ + * name: "greet", + * description: "Generate a greeting", + * args: [{ name: "name", required: true }], + * handler: async (args, event) => ({ + * messages: [ + * { role: "user", content: { type: "text", text: `Hello ${args.name}!` } }, + * ], + * }), + * }); + * + * @example + * // Prompt without arguments + * const helpPrompt = defineMcpPrompt({ + * name: "help", + * description: "Show help information", + * handler: async (event) => ({ + * messages: [ + * { role: "user", content: { type: "text", text: "How can I help?" } }, + * ], + * }), + * }); + * + * @see https://modelcontextprotocol.io/specification/2025-06-18/server/prompts + */ +export function defineMcpPrompt(definition: McpPromptDefinition): McpPromptDefinition { + return definition; +} + +/** + * Define an H3 event handler that implements the Model Context Protocol (MCP) + * over HTTP using JSON-RPC 2.0 as the wire format. + * + * Supports MCP methods: `initialize`, `ping`, `tools/list`, `tools/call`, + * `resources/list`, `resources/read`, `prompts/list`, `prompts/get`, + * and `notifications/initialized`. + * + * @param options - Static options or a function that receives the `H3Event` and returns options (for per-request configuration). + * @returns An H3 `EventHandler`. + * + * @example + * app.all( + * "/mcp", + * defineMcpHandler({ + * name: "my-server", + * version: "1.0.0", + * tools: [echoTool], + * resources: [readmeResource], + * prompts: [greetPrompt], + * }), + * ); + * + * @example + * // Dynamic options based on request context + * app.all( + * "/mcp", + * defineMcpHandler((event) => ({ + * name: "my-server", + * version: "1.0.0", + * tools: getToolsForUser(event), + * })), + * ); + * + * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/transports + */ +export function defineMcpHandler( + options: McpHandlerOptions | ((event: H3Event) => McpHandlerOptions), +): EventHandler { + // For static options, resolve lazy values once and cache across requests + const staticResolved = typeof options !== "function" ? resolveMcpOptions(options) : undefined; + return defineHandler(function _mcpHandler(event) { + const resolved = + staticResolved ?? resolveMcpOptions(typeof options === "function" ? options(event) : options); + return handleMcpRequest(resolved, event); + }); +} diff --git a/test/mcp.test.ts b/test/mcp.test.ts new file mode 100644 index 000000000..5828d27d9 --- /dev/null +++ b/test/mcp.test.ts @@ -0,0 +1,609 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { + defineMcpTool, + defineMcpResource, + defineMcpPrompt, + defineMcpHandler, +} from "../src/index.ts"; +import { describeMatrix } from "./_setup.ts"; + +// ---- Definition Helpers (unit tests) ---- + +describe("defineMcpTool", () => { + it("should return the definition as-is", () => { + const handler = async () => ({ + content: [{ type: "text" as const, text: "hello" }], + }); + const tool = defineMcpTool({ + name: "test-tool", + description: "A test tool", + handler, + }); + expect(tool.name).toBe("test-tool"); + expect(tool.description).toBe("A test tool"); + expect(tool.handler).toBe(handler); + }); + + it("should preserve inputSchema", () => { + const tool = defineMcpTool({ + name: "with-schema", + inputSchema: { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, + handler: async ({ message }) => ({ + content: [{ type: "text" as const, text: message as string }], + }), + }); + expect(tool.name).toBe("with-schema"); + expect(tool.inputSchema).toBeDefined(); + expect(tool.inputSchema!.type).toBe("object"); + }); +}); + +describe("defineMcpResource", () => { + it("should return the definition as-is", () => { + const handler = async (uri: URL) => ({ + contents: [{ uri: uri.toString(), text: "content" }], + }); + const resource = defineMcpResource({ + name: "test-resource", + uri: "file:///test", + description: "A test resource", + handler, + }); + expect(resource.name).toBe("test-resource"); + expect(resource.uri).toBe("file:///test"); + expect(resource.handler).toBe(handler); + }); +}); + +describe("defineMcpPrompt", () => { + it("should return the definition as-is", () => { + const handler = async () => ({ + messages: [ + { + role: "user" as const, + content: { type: "text" as const, text: "Hello!" }, + }, + ], + }); + const prompt = defineMcpPrompt({ + name: "test-prompt", + description: "A test prompt", + handler, + }); + expect(prompt.name).toBe("test-prompt"); + expect(prompt.description).toBe("A test prompt"); + expect(prompt.handler).toBe(handler); + }); + + it("should preserve args", () => { + const prompt = defineMcpPrompt({ + name: "with-args", + args: [{ name: "name", required: true }], + handler: async (args: Record) => ({ + messages: [ + { + role: "user" as const, + content: { type: "text" as const, text: `Hello ${args.name}!` }, + }, + ], + }), + }); + expect(prompt.args).toBeDefined(); + expect(prompt.args![0].name).toBe("name"); + }); +}); + +// ---- MCP Handler (integration tests) ---- + +describeMatrix("defineMcpHandler", (t, { it, expect }) => { + const echoTool = defineMcpTool({ + name: "echo", + description: "Echo back a message", + inputSchema: { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, + handler: async ({ message }) => ({ + content: [{ type: "text" as const, text: message as string }], + }), + }); + + const greetTool = defineMcpTool({ + name: "greet", + description: "Greet someone", + handler: async () => ({ + content: [{ type: "text" as const, text: "Hello!" }], + }), + }); + + const readmeResource = defineMcpResource({ + name: "readme", + uri: "file:///readme", + description: "Project README", + handler: async (uri) => ({ + contents: [{ uri: uri.toString(), text: "# My Project\nHello world" }], + }), + }); + + const greetPrompt = defineMcpPrompt({ + name: "greet", + description: "Generate a greeting", + args: [{ name: "name", required: true }], + handler: async (args: Record) => ({ + messages: [ + { + role: "user" as const, + content: { type: "text" as const, text: `Hello ${args.name}!` }, + }, + ], + }), + }); + + beforeEach(() => { + t.app.all( + "/mcp", + defineMcpHandler({ + name: "test-server", + version: "1.0.0", + tools: [echoTool, greetTool], + resources: [readmeResource], + prompts: [greetPrompt], + }), + ); + }); + + // Helper to send JSON-RPC requests + function jsonRpc(method: string, params?: unknown, id: number = 1) { + return t.fetch("/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), + }); + } + + // Helper to send JSON-RPC notifications (no id) + function jsonRpcNotification(method: string, params?: unknown) { + return t.fetch("/mcp", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", method, params }), + }); + } + + it("should handle initialize", async () => { + const res = await jsonRpc("initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.jsonrpc).toBe("2.0"); + expect(body.id).toBe(1); + expect(body.result.protocolVersion).toBe("2025-06-18"); + expect(body.result.serverInfo.name).toBe("test-server"); + expect(body.result.serverInfo.version).toBe("1.0.0"); + expect(body.result.capabilities).toBeDefined(); + }); + + it("should handle tools/list", async () => { + const res = await jsonRpc("tools/list", {}, 2); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.result.tools).toBeDefined(); + expect(body.result.tools.length).toBe(2); + + const toolNames = body.result.tools.map((t: any) => t.name); + expect(toolNames).toContain("echo"); + expect(toolNames).toContain("greet"); + }); + + it("should handle tools/call", async () => { + const res = await jsonRpc( + "tools/call", + { name: "echo", arguments: { message: "hello world" } }, + 2, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.result.content).toEqual([{ type: "text", text: "hello world" }]); + }); + + it("should handle tools/call without arguments", async () => { + const res = await jsonRpc("tools/call", { name: "greet" }, 2); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.result.content).toEqual([{ type: "text", text: "Hello!" }]); + }); + + it("should handle resources/list", async () => { + const res = await jsonRpc("resources/list", {}, 2); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.result.resources).toBeDefined(); + expect(body.result.resources.length).toBe(1); + expect(body.result.resources[0].name).toBe("readme"); + expect(body.result.resources[0].uri).toBe("file:///readme"); + }); + + it("should handle resources/read", async () => { + const res = await jsonRpc("resources/read", { uri: "file:///readme" }, 2); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.result.contents).toBeDefined(); + expect(body.result.contents[0].text).toBe("# My Project\nHello world"); + }); + + it("should handle prompts/list", async () => { + const res = await jsonRpc("prompts/list", {}, 2); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.result.prompts).toBeDefined(); + expect(body.result.prompts.length).toBe(1); + expect(body.result.prompts[0].name).toBe("greet"); + }); + + it("should handle prompts/get", async () => { + const res = await jsonRpc("prompts/get", { name: "greet", arguments: { name: "World" } }, 2); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.result.messages).toBeDefined(); + expect(body.result.messages[0].content.text).toBe("Hello World!"); + }); + + it("should handle ping", async () => { + const res = await jsonRpc("ping", {}, 2); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.result).toBeDefined(); + }); + + it("should return 202 for notifications", async () => { + const res = await jsonRpcNotification("notifications/initialized"); + expect(res.status).toBe(202); + }); + + it("should return 405 for GET", async () => { + const res = await t.fetch("/mcp"); + expect(res.status).toBe(405); + }); + + it("should return 200 for DELETE", async () => { + const res = await t.fetch("/mcp", { method: "DELETE" }); + expect(res.status).toBe(200); + }); + + it("should include title and instructions in initialize", async () => { + t.app.all( + "/mcp-full", + defineMcpHandler({ + name: "full-server", + version: "1.0.0", + title: "Full Server Display Name", + instructions: "Use this server for testing", + tools: [echoTool], + }), + ); + + const res = await t.fetch("/mcp-full", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }, + }), + }); + + const body = await res.json(); + expect(body.result.serverInfo.title).toBe("Full Server Display Name"); + expect(body.result.instructions).toBe("Use this server for testing"); + }); + + it("should not include title/instructions when not set", async () => { + const res = await jsonRpc("initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + + const body = await res.json(); + expect(body.result.serverInfo.title).toBeUndefined(); + expect(body.result.instructions).toBeUndefined(); + }); + + it("should reject unsupported MCP-Protocol-Version header", async () => { + const res = await t.fetch("/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + "mcp-protocol-version": "9999-01-01", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "ping", + }), + }); + + expect(res.status).toBe(400); + }); + + it("should accept supported MCP-Protocol-Version header", async () => { + const res = await t.fetch("/mcp", { + method: "POST", + headers: { + "content-type": "application/json", + "mcp-protocol-version": "2025-06-18", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "ping", + }), + }); + + expect(res.status).toBe(200); + }); + + it("should include outputSchema in tools/list", async () => { + const toolWithOutput = defineMcpTool({ + name: "structured", + description: "Returns structured data", + outputSchema: { + type: "object", + properties: { result: { type: "string" } }, + }, + handler: async () => ({ + content: [{ type: "text" as const, text: "ok" }], + structuredContent: { result: "ok" }, + }), + }); + + t.app.all( + "/mcp-output", + defineMcpHandler({ + name: "output-server", + version: "1.0.0", + tools: [toolWithOutput], + }), + ); + + const res = await t.fetch("/mcp-output", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/list", + }), + }); + + const body = await res.json(); + expect(body.result.tools[0].outputSchema).toEqual({ + type: "object", + properties: { result: { type: "string" } }, + }); + }); + + it("should include size in resources/list", async () => { + const sizedResource = defineMcpResource({ + name: "sized", + uri: "file:///sized", + size: 1024, + handler: async (uri) => ({ + contents: [{ uri: uri.toString(), text: "data" }], + }), + }); + + t.app.all( + "/mcp-sized", + defineMcpHandler({ + name: "sized-server", + version: "1.0.0", + resources: [sizedResource], + }), + ); + + const res = await t.fetch("/mcp-sized", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "resources/list", + }), + }); + + const body = await res.json(); + expect(body.result.resources[0].size).toBe(1024); + }); + + it("should support dynamic options via function", async () => { + t.app.all( + "/mcp-dynamic", + defineMcpHandler(() => ({ + name: "dynamic-server", + version: "2.0.0", + tools: [echoTool], + })), + ); + + const res = await t.fetch("/mcp-dynamic", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }, + }), + }); + + expect(res.status).toBe(200); + const body = await res.json(); + expect(body.result.serverInfo.name).toBe("dynamic-server"); + expect(body.result.serverInfo.version).toBe("2.0.0"); + }); +}); + +// ---- Lazy options (integration tests) ---- + +describeMatrix("defineMcpHandler (lazy options)", (t, { it, expect }) => { + const echoTool = defineMcpTool({ + name: "echo", + description: "Echo back a message", + inputSchema: { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, + handler: async ({ message }) => ({ + content: [{ type: "text" as const, text: message as string }], + }), + }); + + const readmeResource = defineMcpResource({ + name: "readme", + uri: "file:///readme", + description: "Project README", + handler: async (uri) => ({ + contents: [{ uri: uri.toString(), text: "# Lazy Resource" }], + }), + }); + + const helpPrompt = defineMcpPrompt({ + name: "help", + description: "Show help", + handler: async () => ({ + messages: [ + { + role: "user" as const, + content: { type: "text" as const, text: "How can I help?" }, + }, + ], + }), + }); + + const readmeToolDummy = defineMcpTool({ + name: "dummy", + description: "A dummy tool", + handler: async () => ({ + content: [{ type: "text" as const, text: "dummy" }], + }), + }); + + // Helper to send JSON-RPC requests + function jsonRpc(path: string, method: string, params?: unknown, id: number = 1) { + return t.fetch(path, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ jsonrpc: "2.0", id, method, params }), + }); + } + + it("should resolve lazy tool items", async () => { + const toolFn = vi.fn(async () => echoTool); + t.app.all("/mcp-lazy", defineMcpHandler({ name: "lazy", version: "1.0.0", tools: [toolFn] })); + + const listRes = await jsonRpc("/mcp-lazy", "tools/list"); + expect((await listRes.json()).result.tools[0].name).toBe("echo"); + + const callRes = await jsonRpc("/mcp-lazy", "tools/call", { + name: "echo", + arguments: { message: "lazy!" }, + }); + expect((await callRes.json()).result.content).toEqual([{ type: "text", text: "lazy!" }]); + + // Factory function should be called only once (cached) + expect(toolFn).toHaveBeenCalledTimes(1); + }); + + it("should resolve lazy resource items", async () => { + const resourceFn = vi.fn(async () => readmeResource); + t.app.all( + "/mcp-lazy-res", + defineMcpHandler({ name: "lazy", version: "1.0.0", resources: [resourceFn] }), + ); + + const listRes = await jsonRpc("/mcp-lazy-res", "resources/list"); + expect((await listRes.json()).result.resources[0].name).toBe("readme"); + + const readRes = await jsonRpc("/mcp-lazy-res", "resources/read", { uri: "file:///readme" }); + expect((await readRes.json()).result.contents[0].text).toBe("# Lazy Resource"); + + expect(resourceFn).toHaveBeenCalledTimes(1); + }); + + it("should resolve lazy prompt items", async () => { + const promptFn = vi.fn(async () => helpPrompt); + t.app.all( + "/mcp-lazy-prompt", + defineMcpHandler({ name: "lazy", version: "1.0.0", prompts: [promptFn] }), + ); + + const listRes = await jsonRpc("/mcp-lazy-prompt", "prompts/list"); + expect((await listRes.json()).result.prompts[0].name).toBe("help"); + + const getRes = await jsonRpc("/mcp-lazy-prompt", "prompts/get", { name: "help" }); + expect((await getRes.json()).result.messages[0].content.text).toBe("How can I help?"); + + expect(promptFn).toHaveBeenCalledTimes(1); + }); + + it("should resolve mixed static and lazy items", async () => { + const lazyTool = vi.fn(async () => echoTool); + t.app.all( + "/mcp-lazy-mixed", + defineMcpHandler({ + name: "lazy", + version: "1.0.0", + tools: [lazyTool, readmeToolDummy], + }), + ); + + const listRes = await jsonRpc("/mcp-lazy-mixed", "tools/list"); + const tools = (await listRes.json()).result.tools; + expect(tools.length).toBe(2); + expect(tools[0].name).toBe("echo"); + expect(tools[1].name).toBe("dummy"); + }); + + it("should report lazy capabilities in initialize", async () => { + t.app.all( + "/mcp-lazy-init", + defineMcpHandler({ + name: "lazy", + version: "1.0.0", + tools: [async () => echoTool], + resources: [async () => readmeResource], + prompts: [async () => helpPrompt], + }), + ); + + const res = await jsonRpc("/mcp-lazy-init", "initialize", { + protocolVersion: "2025-06-18", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + const body = await res.json(); + expect(body.result.capabilities.tools).toBeDefined(); + expect(body.result.capabilities.resources).toBeDefined(); + expect(body.result.capabilities.prompts).toBeDefined(); + }); +}); diff --git a/test/unit/package.test.ts b/test/unit/package.test.ts index 184839f12..dce6568d5 100644 --- a/test/unit/package.test.ts +++ b/test/unit/package.test.ts @@ -34,6 +34,10 @@ describe("h3 package", () => { "defineJsonRpcHandler", "defineJsonRpcWebSocketHandler", "defineLazyEventHandler", + "defineMcpHandler", + "defineMcpPrompt", + "defineMcpResource", + "defineMcpTool", "defineMiddleware", "defineNodeHandler", "defineNodeListener",