From 12115c06b76617b5fbd118e7ba741cc44c6c51d4 Mon Sep 17 00:00:00 2001 From: Hugo Richard Date: Fri, 6 Feb 2026 15:59:23 +0000 Subject: [PATCH 1/6] feat: add native MCP support --- examples/mcp.mjs | 110 ++++++++++++ package.json | 9 +- pnpm-lock.yaml | 203 +++++++++++++++++++++ src/index.ts | 17 ++ src/utils/internal/mcp.ts | 179 +++++++++++++++++++ src/utils/mcp.ts | 157 +++++++++++++++++ test/mcp.test.ts | 363 ++++++++++++++++++++++++++++++++++++++ 7 files changed, 1036 insertions(+), 2 deletions(-) create mode 100644 examples/mcp.mjs create mode 100644 src/utils/internal/mcp.ts create mode 100644 src/utils/mcp.ts create mode 100644 test/mcp.test.ts diff --git a/examples/mcp.mjs b/examples/mcp.mjs new file mode 100644 index 000000000..f61757708 --- /dev/null +++ b/examples/mcp.mjs @@ -0,0 +1,110 @@ +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/package.json b/package.json index 2d3623cf7..caeefe70e 100644 --- a/package.json +++ b/package.json @@ -89,14 +89,19 @@ "typescript": "^5.9.3", "vite": "^7.3.1", "vitest": "^4.0.18", - "zod": "^4.3.6" + "zod": "^4.3.6", + "@modelcontextprotocol/sdk": "^1.26.0" }, "peerDependencies": { - "crossws": "^0.4.1" + "crossws": "^0.4.1", + "@modelcontextprotocol/sdk": "^1.25.0" }, "peerDependenciesMeta": { "crossws": { "optional": true + }, + "@modelcontextprotocol/sdk": { + "optional": true } }, "resolutions": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f700934c2..c86f805d0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,6 +24,9 @@ importers: '@mitata/counters': specifier: ^0.0.8 version: 0.0.8 + '@modelcontextprotocol/sdk': + specifier: ^1.26.0 + version: 1.26.0(zod@4.3.6) '@types/connect': specifier: ^3.4.38 version: 3.4.38 @@ -359,6 +362,12 @@ packages: resolution: {integrity: sha512-N40+OOBXdI7TcKfxA0/EsD1eI+Zew4gwPPC9PJljAniIbNra0OjzTC4GsO/BjIMHq+cq1ogzL4/KZQhEVLEQ7w==} engines: {node: '>=20.0.0'} + '@hono/node-server@1.19.9': + resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -375,6 +384,16 @@ packages: '@mitata/counters@0.0.8': resolution: {integrity: sha512-f11w0Y1ETFlarDP7CePj8Z+y8Gv5Ax4gMxWsEwrqh0kH/YIY030Ezx5SUJeQg0YPTZ2OHKGcLG1oGJbIqHzaJA==} + '@modelcontextprotocol/sdk@1.26.0': + resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} @@ -1064,6 +1083,17 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + assertion-error@2.0.1: resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} engines: {node: '>=12'} @@ -1168,6 +1198,14 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + crossws@0.4.4: resolution: {integrity: sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg==} peerDependencies: @@ -1303,6 +1341,14 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + eventsource-parser@3.0.6: + resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + exact-mirror@0.2.2: resolution: {integrity: sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA==} peerDependencies: @@ -1315,6 +1361,12 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.2.1: + resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -1325,6 +1377,12 @@ packages: fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-uri@3.1.0: + resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} @@ -1441,6 +1499,10 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + ip-address@10.0.1: + resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} + engines: {node: '>= 12'} + ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -1470,6 +1532,9 @@ packages: resolution: {integrity: sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==} engines: {node: '>=16'} + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -1486,6 +1551,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.1.3: + resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} + js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1494,6 +1562,12 @@ packages: engines: {node: '>=6'} hasBin: true + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + knitwork@1.3.0: resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} @@ -1575,6 +1649,10 @@ packages: engines: {node: '>=18'} hasBin: true + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -1633,6 +1711,10 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -1649,6 +1731,10 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -1695,6 +1781,10 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -1763,6 +1853,14 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -1956,6 +2054,11 @@ packages: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -1980,6 +2083,11 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} + zod-to-json-schema@3.25.1: + resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} + peerDependencies: + zod: ^3.25 || ^4 + zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2128,6 +2236,10 @@ snapshots: - bufferutil - utf-8-validate + '@hono/node-server@1.19.9(hono@4.11.7)': + dependencies: + hono: 4.11.7 + '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2144,6 +2256,28 @@ snapshots: '@mitata/counters@0.0.8': {} + '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)': + dependencies: + '@hono/node-server': 1.19.9(hono@4.11.7) + ajv: 8.17.1 + ajv-formats: 3.0.1(ajv@8.17.1) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.2.1(express@5.2.1) + hono: 4.11.7 + jose: 6.1.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 4.3.6 + zod-to-json-schema: 3.25.1(zod@4.3.6) + transitivePeerDependencies: + - supports-color + '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.8.1 @@ -2617,6 +2751,17 @@ snapshots: acorn@8.15.0: {} + ajv-formats@3.0.1(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 + + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.0 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + assertion-error@2.0.1: {} ast-kit@3.0.0-beta.1: @@ -2761,6 +2906,17 @@ snapshots: cookie@1.1.1: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + crossws@0.4.4(srvx@0.11.2): optionalDependencies: srvx: 0.11.2 @@ -2875,12 +3031,23 @@ snapshots: etag@1.8.1: {} + eventsource-parser@3.0.6: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + exact-mirror@0.2.2(@sinclair/typebox@0.34.41): optionalDependencies: '@sinclair/typebox': 0.34.41 expect-type@1.3.0: {} + express-rate-limit@8.2.1(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.0.1 + express@5.2.1: dependencies: accepts: 2.0.0 @@ -2918,6 +3085,10 @@ snapshots: fast-decode-uri-component@1.0.1: {} + fast-deep-equal@3.1.3: {} + + fast-uri@3.1.0: {} + fastest-levenshtein@1.0.16: {} fdir@6.5.0(picomatch@4.0.3): @@ -3051,6 +3222,8 @@ snapshots: inherits@2.0.4: {} + ip-address@10.0.1: {} + ipaddr.js@1.9.1: {} is-docker@3.0.0: {} @@ -3071,6 +3244,8 @@ snapshots: dependencies: is-inside-container: 1.0.0 + isexe@2.0.0: {} + istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -3086,10 +3261,16 @@ snapshots: jiti@2.6.1: {} + jose@6.1.3: {} + js-tokens@10.0.0: {} jsesc@3.1.0: {} + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + knitwork@1.3.0: {} lodash.deburr@4.1.0: {} @@ -3157,6 +3338,8 @@ snapshots: pathe: 2.0.3 tinyexec: 1.0.2 + object-assign@4.1.1: {} + object-inspect@1.13.4: {} obug@2.1.1: {} @@ -3261,6 +3444,8 @@ snapshots: parseurl@1.3.3: {} + path-key@3.1.1: {} + path-to-regexp@8.3.0: {} pathe@2.0.3: {} @@ -3271,6 +3456,8 @@ snapshots: picomatch@4.0.3: {} + pkce-challenge@5.0.1: {} + pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -3323,6 +3510,8 @@ snapshots: readdirp@5.0.0: {} + require-from-string@2.0.2: {} + resolve-pkg-maps@1.0.0: {} rolldown-plugin-dts@0.22.1(@typescript/native-preview@7.0.0-dev.20260205.1)(rolldown@1.0.0-rc.3)(typescript@5.9.3): @@ -3442,6 +3631,12 @@ snapshots: setprototypeof@1.2.0: {} + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -3597,6 +3792,10 @@ snapshots: whatwg-mimetype@3.0.0: {} + which@2.0.2: + dependencies: + isexe: 2.0.0 + why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -3610,4 +3809,8 @@ snapshots: dependencies: is-wsl: 3.1.0 + zod-to-json-schema@3.25.1(zod@4.3.6): + dependencies: + zod: 4.3.6 + zod@4.3.6: {} diff --git a/src/index.ts b/src/index.ts index 31d967a77..c00c843be 100644 --- a/src/index.ts +++ b/src/index.ts @@ -198,6 +198,23 @@ export { defineWebSocket, } from "./utils/ws.ts"; +// MCP + +export { + type McpToolDefinition, + type McpToolCallback, + type McpResourceDefinition, + type McpResourceCallback, + type McpResourceTemplateCallback, + type McpPromptDefinition, + type McpPromptCallback, + 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..934aed0d2 --- /dev/null +++ b/src/utils/internal/mcp.ts @@ -0,0 +1,179 @@ +import type { Transport, TransportSendOptions } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { JSONRPCMessage, RequestId } from "@modelcontextprotocol/sdk/types.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { H3Event } from "../../event.ts"; +import type { McpHandlerOptions } from "../mcp.ts"; +import { readBody } from "../body.ts"; + +/** + * Web-standard MCP transport implementing the SDK's Transport interface. + */ +export class H3McpTransport implements Transport { + private _responseResolver: ((messages: JSONRPCMessage[]) => void) | null = + null; + private _expectedResponses = 0; + private _collectedResponses: JSONRPCMessage[] = []; + + onmessage?: (message: T) => void; + onerror?: (error: Error) => void; + onclose?: () => void; + sessionId?: string; + + async start(): Promise {} + + async send( + message: JSONRPCMessage, + _options?: TransportSendOptions, + ): Promise { + this._collectedResponses.push(message); + if ( + this._responseResolver && + this._collectedResponses.length >= this._expectedResponses + ) { + this._responseResolver(this._collectedResponses); + this._responseResolver = null; + } + } + + async close(): Promise { + this.onclose?.(); + } + + processRequest( + messages: JSONRPCMessage | JSONRPCMessage[], + ): Promise { + const messageList = Array.isArray(messages) ? messages : [messages]; + + // count requests that expect responses (notifications have no "id") + this._expectedResponses = 0; + for (const msg of messageList) { + if ("id" in msg && (msg as { id?: RequestId }).id !== undefined) { + this._expectedResponses++; + } + } + + // all notifications, no responses expected + if (this._expectedResponses === 0) { + for (const msg of messageList) { + this.onmessage?.(msg); + } + return Promise.resolve([]); + } + + this._collectedResponses = []; + + return new Promise((resolve) => { + this._responseResolver = resolve; + for (const msg of messageList) { + this.onmessage?.(msg); + } + }); + } +} + +export async function createMcpServer( + options: McpHandlerOptions, +): Promise { + const { McpServer: McpServerClass } = (await import( + "@modelcontextprotocol/sdk/server/mcp.js" + )) as { McpServer: typeof McpServer }; + + const server = new McpServerClass({ + name: options.name, + version: options.version, + }); + + if (options.tools) { + for (const tool of options.tools) { + server.registerTool( + tool.name, + { + title: tool.title, + description: tool.description, + inputSchema: tool.inputSchema, + outputSchema: tool.outputSchema, + annotations: tool.annotations, + }, + tool.handler as any, + ); + } + } + + if (options.resources) { + for (const resource of options.resources) { + server.registerResource( + resource.name, + resource.uri as any, + { + title: resource.title, + description: resource.description, + ...resource.metadata, + }, + resource.handler as any, + ); + } + } + + if (options.prompts) { + for (const prompt of options.prompts) { + server.registerPrompt( + prompt.name, + { + title: prompt.title, + description: prompt.description, + argsSchema: prompt.argsSchema, + }, + prompt.handler as any, + ); + } + } + + return server; +} + +export async function handleMcpRequest( + options: McpHandlerOptions, + 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 server = await createMcpServer(options); + const transport = new H3McpTransport(); + + await server.connect(transport); + + try { + const body = await readBody(event) as JSONRPCMessage | JSONRPCMessage[]; + const isBatch = Array.isArray(body); + const responses = await transport.processRequest(body); + + await server.close(); + + if (responses.length === 0) { + return new Response(null, { status: 202 }); + } + + const responseBody = isBatch + ? JSON.stringify(responses) + : JSON.stringify(responses[0]); + + return new Response(responseBody, { + status: 200, + headers: { "content-type": "application/json" }, + }); + } catch (error) { + await server.close(); + throw error; + } +} diff --git a/src/utils/mcp.ts b/src/utils/mcp.ts new file mode 100644 index 000000000..852a0e6cd --- /dev/null +++ b/src/utils/mcp.ts @@ -0,0 +1,157 @@ +import type { + CallToolResult, + GetPromptResult, + ReadResourceResult, + ToolAnnotations, + ServerRequest, + ServerNotification, +} from "@modelcontextprotocol/sdk/types.js"; +import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; +import type { + ResourceTemplate, + ResourceMetadata, +} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { + ZodRawShapeCompat, + ShapeOutput, +} from "@modelcontextprotocol/sdk/server/zod-compat.js"; + +import { defineHandler } from "../handler.ts"; +import { handleMcpRequest } from "./internal/mcp.ts"; + +import type { H3Event } from "../event.ts"; +import type { EventHandler } from "../types/handler.ts"; + +// --- tool types --- + +export type McpToolCallback< + Args extends ZodRawShapeCompat | undefined = undefined, +> = Args extends ZodRawShapeCompat + ? ( + args: ShapeOutput, + extra: RequestHandlerExtra, + ) => CallToolResult | Promise + : ( + extra: RequestHandlerExtra, + ) => CallToolResult | Promise; + +export interface McpToolDefinition< + InputSchema extends ZodRawShapeCompat | undefined = undefined, + OutputSchema extends ZodRawShapeCompat | undefined = undefined, +> { + name: string; + title?: string; + description?: string; + inputSchema?: InputSchema; + outputSchema?: OutputSchema; + annotations?: ToolAnnotations; + handler: McpToolCallback; +} + +// --- resource types --- + +export type McpResourceCallback = ( + uri: URL, + extra: RequestHandlerExtra, +) => ReadResourceResult | Promise; + +export type McpResourceTemplateCallback = ( + uri: URL, + variables: Record, + extra: RequestHandlerExtra, +) => ReadResourceResult | Promise; + +export interface McpResourceDefinition { + name: string; + title?: string; + description?: string; + uri: string | ResourceTemplate; + metadata?: ResourceMetadata; + handler: McpResourceCallback | McpResourceTemplateCallback; +} + +// --- prompt types --- + +export type McpPromptCallback< + Args extends ZodRawShapeCompat | undefined = undefined, +> = Args extends ZodRawShapeCompat + ? ( + args: ShapeOutput, + extra: RequestHandlerExtra, + ) => GetPromptResult | Promise + : ( + extra: RequestHandlerExtra, + ) => GetPromptResult | Promise; + +export interface McpPromptDefinition< + Args extends ZodRawShapeCompat | undefined = undefined, +> { + name: string; + title?: string; + description?: string; + argsSchema?: Args; + handler: McpPromptCallback; +} + +// --- handler options --- + +export interface McpHandlerOptions { + name: string; + version: string; + tools?: McpToolDefinition[]; + resources?: McpResourceDefinition[]; + prompts?: McpPromptDefinition[]; +} + +// --- definition helpers --- + +/** + * Define an MCP tool. + * + * @see https://modelcontextprotocol.io/specification/2025-06-18/server/tools + */ +export function defineMcpTool< + const InputSchema extends ZodRawShapeCompat | undefined = undefined, + const OutputSchema extends ZodRawShapeCompat | undefined = undefined, +>( + definition: McpToolDefinition, +): McpToolDefinition { + return definition; +} + +/** + * Define an MCP resource. + * + * @see https://modelcontextprotocol.io/specification/2025-06-18/server/resources + */ +export function defineMcpResource( + definition: McpResourceDefinition, +): McpResourceDefinition { + return definition; +} + +/** + * Define an MCP prompt. + * + * @see https://modelcontextprotocol.io/specification/2025-06-18/server/prompts + */ +export function defineMcpPrompt< + const Args extends ZodRawShapeCompat | undefined = undefined, +>(definition: McpPromptDefinition): McpPromptDefinition { + return definition; +} + +/** + * Define an MCP event handler. + * + * @see https://modelcontextprotocol.io/specification/2025-06-18/basic/transports + */ +export function defineMcpHandler( + options: McpHandlerOptions | ((event: H3Event) => McpHandlerOptions), +): EventHandler { + return defineHandler(function _mcpHandler(event) { + const resolvedOptions = + typeof options === "function" ? options(event) : options; + return handleMcpRequest(resolvedOptions, event); + }); +} diff --git a/test/mcp.test.ts b/test/mcp.test.ts new file mode 100644 index 000000000..bf394c169 --- /dev/null +++ b/test/mcp.test.ts @@ -0,0 +1,363 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { z } from "zod/v4"; +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: { message: z.string() }, + handler: async ({ message }) => ({ + content: [{ type: "text" as const, text: message }], + }), + }); + expect(tool.name).toBe("with-schema"); + expect(tool.inputSchema).toBeDefined(); + expect(tool.inputSchema!.message).toBeDefined(); + }); +}); + +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 argsSchema", () => { + const prompt = defineMcpPrompt({ + name: "with-args", + argsSchema: { name: z.string() }, + handler: async ({ name }) => ({ + messages: [ + { + role: "user" as const, + content: { type: "text" as const, text: `Hello ${name}!` }, + }, + ], + }), + }); + expect(prompt.argsSchema).toBeDefined(); + expect(prompt.argsSchema!.name).toBeDefined(); + }); +}); + +// ---- MCP Handler (integration tests) ---- + +describeMatrix("defineMcpHandler", (t, { it, expect }) => { + const echoTool = defineMcpTool({ + name: "echo", + description: "Echo back a message", + inputSchema: { message: z.string() }, + handler: async ({ message }) => ({ + content: [{ type: "text" as const, text: message }], + }), + }); + + 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", + argsSchema: { name: z.string() }, + handler: async ({ name }) => ({ + messages: [ + { + role: "user" as const, + content: { type: "text" as const, text: `Hello ${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-03-26", + 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.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 () => { + // First initialize + await jsonRpc("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + + 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 () => { + await jsonRpc("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + + 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 () => { + await jsonRpc("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + + 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 () => { + await jsonRpc("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + + 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 () => { + await jsonRpc("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + + 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 () => { + await jsonRpc("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + + 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 () => { + await jsonRpc("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + + 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 () => { + await jsonRpc("initialize", { + protocolVersion: "2025-03-26", + capabilities: {}, + clientInfo: { name: "test-client", version: "1.0.0" }, + }); + + 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 support dynamic options via function", async () => { + t.app.all( + "/mcp-dynamic", + defineMcpHandler((event) => ({ + 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-03-26", + 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"); + }); +}); From 5274c4d41f36968895d1b718d25f3e421b90904d Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 6 Feb 2026 16:12:36 +0000 Subject: [PATCH 2/6] chore: apply automated updates --- examples/mcp.mjs | 13 ++------ package.json | 8 ++--- src/handler.ts | 2 +- src/utils/internal/mcp.ts | 37 ++++++++--------------- src/utils/mcp.ts | 62 ++++++++++++++++----------------------- test/mcp.test.ts | 16 ++-------- 6 files changed, 49 insertions(+), 89 deletions(-) diff --git a/examples/mcp.mjs b/examples/mcp.mjs index f61757708..0fecdc55c 100644 --- a/examples/mcp.mjs +++ b/examples/mcp.mjs @@ -1,12 +1,5 @@ import { z } from "zod"; -import { - H3, - serve, - defineMcpHandler, - defineMcpTool, - defineMcpResource, - defineMcpPrompt, -} from "h3"; +import { H3, serve, defineMcpHandler, defineMcpTool, defineMcpResource, defineMcpPrompt } from "h3"; export const app = new H3(); @@ -46,9 +39,7 @@ const calculatorTool = defineMcpTool({ break; } return { - content: [ - { type: "text", text: JSON.stringify({ operation, a, b, result }, null, 2) }, - ], + content: [{ type: "text", text: JSON.stringify({ operation, a, b, result }, null, 2) }], }; }, }); diff --git a/package.json b/package.json index caeefe70e..140cc0d8f 100644 --- a/package.json +++ b/package.json @@ -56,6 +56,7 @@ "devDependencies": { "@happy-dom/global-registrator": "^20.5.0", "@mitata/counters": "^0.0.8", + "@modelcontextprotocol/sdk": "^1.26.0", "@types/connect": "^3.4.38", "@types/express": "^5.0.6", "@types/node": "^25.2.1", @@ -89,12 +90,11 @@ "typescript": "^5.9.3", "vite": "^7.3.1", "vitest": "^4.0.18", - "zod": "^4.3.6", - "@modelcontextprotocol/sdk": "^1.26.0" + "zod": "^4.3.6" }, "peerDependencies": { - "crossws": "^0.4.1", - "@modelcontextprotocol/sdk": "^1.25.0" + "@modelcontextprotocol/sdk": "^1.25.0", + "crossws": "^0.4.1" }, "peerDependenciesMeta": { "crossws": { diff --git a/src/handler.ts b/src/handler.ts index c53f1aaf6..a591279dc 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -151,7 +151,7 @@ export function defineLazyEventHandler( return defineHandler(function lazyHandler(event) { return handler ? handler(event) - : (promise ??= Promise.resolve(loader()).then(function resolveLazyHandler (r: any) { + : (promise ??= Promise.resolve(loader()).then(function resolveLazyHandler(r: any) { handler = toEventHandler(r) || toEventHandler(r.default); if (typeof handler !== "function") { // @ts-expect-error diff --git a/src/utils/internal/mcp.ts b/src/utils/internal/mcp.ts index 934aed0d2..d06f13cb1 100644 --- a/src/utils/internal/mcp.ts +++ b/src/utils/internal/mcp.ts @@ -1,4 +1,7 @@ -import type { Transport, TransportSendOptions } from "@modelcontextprotocol/sdk/shared/transport.js"; +import type { + Transport, + TransportSendOptions, +} from "@modelcontextprotocol/sdk/shared/transport.js"; import type { JSONRPCMessage, RequestId } from "@modelcontextprotocol/sdk/types.js"; import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { H3Event } from "../../event.ts"; @@ -9,8 +12,7 @@ import { readBody } from "../body.ts"; * Web-standard MCP transport implementing the SDK's Transport interface. */ export class H3McpTransport implements Transport { - private _responseResolver: ((messages: JSONRPCMessage[]) => void) | null = - null; + private _responseResolver: ((messages: JSONRPCMessage[]) => void) | null = null; private _expectedResponses = 0; private _collectedResponses: JSONRPCMessage[] = []; @@ -21,15 +23,9 @@ export class H3McpTransport implements Transport { async start(): Promise {} - async send( - message: JSONRPCMessage, - _options?: TransportSendOptions, - ): Promise { + async send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise { this._collectedResponses.push(message); - if ( - this._responseResolver && - this._collectedResponses.length >= this._expectedResponses - ) { + if (this._responseResolver && this._collectedResponses.length >= this._expectedResponses) { this._responseResolver(this._collectedResponses); this._responseResolver = null; } @@ -39,9 +35,7 @@ export class H3McpTransport implements Transport { this.onclose?.(); } - processRequest( - messages: JSONRPCMessage | JSONRPCMessage[], - ): Promise { + processRequest(messages: JSONRPCMessage | JSONRPCMessage[]): Promise { const messageList = Array.isArray(messages) ? messages : [messages]; // count requests that expect responses (notifications have no "id") @@ -71,12 +65,9 @@ export class H3McpTransport implements Transport { } } -export async function createMcpServer( - options: McpHandlerOptions, -): Promise { - const { McpServer: McpServerClass } = (await import( - "@modelcontextprotocol/sdk/server/mcp.js" - )) as { McpServer: typeof McpServer }; +export async function createMcpServer(options: McpHandlerOptions): Promise { + const { McpServer: McpServerClass } = + (await import("@modelcontextprotocol/sdk/server/mcp.js")) as { McpServer: typeof McpServer }; const server = new McpServerClass({ name: options.name, @@ -154,7 +145,7 @@ export async function handleMcpRequest( await server.connect(transport); try { - const body = await readBody(event) as JSONRPCMessage | JSONRPCMessage[]; + const body = (await readBody(event)) as JSONRPCMessage | JSONRPCMessage[]; const isBatch = Array.isArray(body); const responses = await transport.processRequest(body); @@ -164,9 +155,7 @@ export async function handleMcpRequest( return new Response(null, { status: 202 }); } - const responseBody = isBatch - ? JSON.stringify(responses) - : JSON.stringify(responses[0]); + const responseBody = isBatch ? JSON.stringify(responses) : JSON.stringify(responses[0]); return new Response(responseBody, { status: 200, diff --git a/src/utils/mcp.ts b/src/utils/mcp.ts index 852a0e6cd..5381fb7cd 100644 --- a/src/utils/mcp.ts +++ b/src/utils/mcp.ts @@ -7,10 +7,7 @@ import type { ServerNotification, } from "@modelcontextprotocol/sdk/types.js"; import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { - ResourceTemplate, - ResourceMetadata, -} from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ResourceTemplate, ResourceMetadata } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { ZodRawShapeCompat, ShapeOutput, @@ -24,16 +21,15 @@ import type { EventHandler } from "../types/handler.ts"; // --- tool types --- -export type McpToolCallback< - Args extends ZodRawShapeCompat | undefined = undefined, -> = Args extends ZodRawShapeCompat - ? ( - args: ShapeOutput, - extra: RequestHandlerExtra, - ) => CallToolResult | Promise - : ( - extra: RequestHandlerExtra, - ) => CallToolResult | Promise; +export type McpToolCallback = + Args extends ZodRawShapeCompat + ? ( + args: ShapeOutput, + extra: RequestHandlerExtra, + ) => CallToolResult | Promise + : ( + extra: RequestHandlerExtra, + ) => CallToolResult | Promise; export interface McpToolDefinition< InputSchema extends ZodRawShapeCompat | undefined = undefined, @@ -72,20 +68,17 @@ export interface McpResourceDefinition { // --- prompt types --- -export type McpPromptCallback< - Args extends ZodRawShapeCompat | undefined = undefined, -> = Args extends ZodRawShapeCompat - ? ( - args: ShapeOutput, - extra: RequestHandlerExtra, - ) => GetPromptResult | Promise - : ( - extra: RequestHandlerExtra, - ) => GetPromptResult | Promise; - -export interface McpPromptDefinition< - Args extends ZodRawShapeCompat | undefined = undefined, -> { +export type McpPromptCallback = + Args extends ZodRawShapeCompat + ? ( + args: ShapeOutput, + extra: RequestHandlerExtra, + ) => GetPromptResult | Promise + : ( + extra: RequestHandlerExtra, + ) => GetPromptResult | Promise; + +export interface McpPromptDefinition { name: string; title?: string; description?: string; @@ -124,9 +117,7 @@ export function defineMcpTool< * * @see https://modelcontextprotocol.io/specification/2025-06-18/server/resources */ -export function defineMcpResource( - definition: McpResourceDefinition, -): McpResourceDefinition { +export function defineMcpResource(definition: McpResourceDefinition): McpResourceDefinition { return definition; } @@ -135,9 +126,9 @@ export function defineMcpResource( * * @see https://modelcontextprotocol.io/specification/2025-06-18/server/prompts */ -export function defineMcpPrompt< - const Args extends ZodRawShapeCompat | undefined = undefined, ->(definition: McpPromptDefinition): McpPromptDefinition { +export function defineMcpPrompt( + definition: McpPromptDefinition, +): McpPromptDefinition { return definition; } @@ -150,8 +141,7 @@ export function defineMcpHandler( options: McpHandlerOptions | ((event: H3Event) => McpHandlerOptions), ): EventHandler { return defineHandler(function _mcpHandler(event) { - const resolvedOptions = - typeof options === "function" ? options(event) : options; + const resolvedOptions = typeof options === "function" ? options(event) : options; return handleMcpRequest(resolvedOptions, event); }); } diff --git a/test/mcp.test.ts b/test/mcp.test.ts index bf394c169..ea848a84e 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -217,9 +217,7 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { ); expect(res.status).toBe(200); const body = await res.json(); - expect(body.result.content).toEqual([ - { type: "text", text: "hello world" }, - ]); + expect(body.result.content).toEqual([{ type: "text", text: "hello world" }]); }); it("should handle tools/call without arguments", async () => { @@ -258,11 +256,7 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { clientInfo: { name: "test-client", version: "1.0.0" }, }); - const res = await jsonRpc( - "resources/read", - { uri: "file:///readme" }, - 2, - ); + 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(); @@ -291,11 +285,7 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { clientInfo: { name: "test-client", version: "1.0.0" }, }); - const res = await jsonRpc( - "prompts/get", - { name: "greet", arguments: { name: "World" } }, - 2, - ); + 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(); From 2a865d75d4e77073bcbd3eedf54083d7edc3e3eb Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 27 Feb 2026 22:09:55 +0100 Subject: [PATCH 3/6] feat(mcp): remove `@modelcontextprotocol/sdk` dependency implement native MCP protocol handling on top of h3's built-in JSON-RPC infrastructure, eliminating the heavy SDK dependency entirely. Co-Authored-By: Claude Opus 4.6 --- docs/2.utils/6.mcp.md | 135 +++++++++++++ package.json | 5 - src/index.ts | 23 ++- src/utils/internal/mcp.ts | 290 ++++++++++++++------------- src/utils/json-rpc.ts | 6 +- src/utils/mcp.ts | 406 +++++++++++++++++++++++++++++++------- test/mcp.test.ts | 88 +++------ 7 files changed, 672 insertions(+), 281 deletions(-) diff --git a/docs/2.utils/6.mcp.md b/docs/2.utils/6.mcp.md index 2a73de639..5677f3434 100644 --- a/docs/2.utils/6.mcp.md +++ b/docs/2.utils/6.mcp.md @@ -6,8 +6,139 @@ 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 +207,8 @@ app.get( ); ``` +### `processJsonRpcBody(body, methodMap, context)` + +Validates and processes a parsed JSON-RPC body (single or batch). + diff --git a/package.json b/package.json index 119b6e290..99d705353 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,6 @@ "devDependencies": { "@happy-dom/global-registrator": "^20.7.0", "@mitata/counters": "^0.0.8", - "@modelcontextprotocol/sdk": "^1.26.0", "@types/connect": "^3.4.38", "@types/express": "^5.0.6", "@types/node": "^25.3.2", @@ -93,15 +92,11 @@ "zod": "^4.3.6" }, "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.25.0", "crossws": "^0.4.1" }, "peerDependenciesMeta": { "crossws": { "optional": true - }, - "@modelcontextprotocol/sdk": { - "optional": true } }, "resolutions": { diff --git a/src/index.ts b/src/index.ts index 9276b93be..e07d3294c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -214,13 +214,28 @@ export type { // MCP export { - type McpToolDefinition, + 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 McpResourceDefinition, + type McpToolDefinition, type McpResourceCallback, - type McpResourceTemplateCallback, - type McpPromptDefinition, + type McpResourceDefinition, type McpPromptCallback, + type McpPromptArgument, + type McpPromptDefinition, type McpHandlerOptions, defineMcpHandler, defineMcpTool, diff --git a/src/utils/internal/mcp.ts b/src/utils/internal/mcp.ts index d06f13cb1..4111a34f5 100644 --- a/src/utils/internal/mcp.ts +++ b/src/utils/internal/mcp.ts @@ -1,168 +1,186 @@ -import type { - Transport, - TransportSendOptions, -} from "@modelcontextprotocol/sdk/shared/transport.js"; -import type { JSONRPCMessage, RequestId } from "@modelcontextprotocol/sdk/types.js"; -import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import type { H3Event } from "../../event.ts"; +import type { JsonRpcMethod, JsonRpcRequest } from "../json-rpc.ts"; import type { McpHandlerOptions } from "../mcp.ts"; -import { readBody } from "../body.ts"; - -/** - * Web-standard MCP transport implementing the SDK's Transport interface. - */ -export class H3McpTransport implements Transport { - private _responseResolver: ((messages: JSONRPCMessage[]) => void) | null = null; - private _expectedResponses = 0; - private _collectedResponses: JSONRPCMessage[] = []; - - onmessage?: (message: T) => void; - onerror?: (error: Error) => void; - onclose?: () => void; - sessionId?: string; - - async start(): Promise {} - - async send(message: JSONRPCMessage, _options?: TransportSendOptions): Promise { - this._collectedResponses.push(message); - if (this._responseResolver && this._collectedResponses.length >= this._expectedResponses) { - this._responseResolver(this._collectedResponses); - this._responseResolver = null; - } - } +import { processJsonRpcBody, createJsonRpcError, createMethodMap } from "../json-rpc.ts"; +import { HTTPError } from "../../error.ts"; - async close(): Promise { - this.onclose?.(); - } +const MCP_PROTOCOL_VERSION = "2025-03-26"; - processRequest(messages: JSONRPCMessage | JSONRPCMessage[]): Promise { - const messageList = Array.isArray(messages) ? messages : [messages]; +export async function handleMcpRequest( + options: McpHandlerOptions, + event: H3Event, +): Promise { + const method = event.req.method; - // count requests that expect responses (notifications have no "id") - this._expectedResponses = 0; - for (const msg of messageList) { - if ("id" in msg && (msg as { id?: RequestId }).id !== undefined) { - this._expectedResponses++; - } - } + if (method === "DELETE") { + return new Response(null, { status: 200 }); + } - // all notifications, no responses expected - if (this._expectedResponses === 0) { - for (const msg of messageList) { - this.onmessage?.(msg); - } - return Promise.resolve([]); - } + if (method !== "POST") { + return new Response("Method not allowed", { + status: 405, + headers: { allow: "POST, DELETE" }, + }); + } - this._collectedResponses = []; + const methods = buildMcpMethodMap(options); + const methodMap = createMethodMap(methods); - return new Promise((resolve) => { - this._responseResolver = resolve; - for (const msg of messageList) { - this.onmessage?.(msg); - } + 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" }, }); } -} -export async function createMcpServer(options: McpHandlerOptions): Promise { - const { McpServer: McpServerClass } = - (await import("@modelcontextprotocol/sdk/server/mcp.js")) as { McpServer: typeof McpServer }; + const result = await processJsonRpcBody(body, methodMap, event); + + if (result === undefined) { + return new Response(null, { status: 202 }); + } - const server = new McpServerClass({ - name: options.name, - version: options.version, + return new Response(JSON.stringify(result), { + status: 200, + headers: { "content-type": "application/json" }, }); +} - if (options.tools) { - for (const tool of options.tools) { - server.registerTool( - tool.name, - { - title: tool.title, - description: tool.description, - inputSchema: tool.inputSchema, - outputSchema: tool.outputSchema, - annotations: tool.annotations, - }, - tool.handler as any, - ); - } - } +// --- Internal helpers --- - if (options.resources) { - for (const resource of options.resources) { - server.registerResource( - resource.name, - resource.uri as any, - { - title: resource.title, - description: resource.description, - ...resource.metadata, - }, - resource.handler as any, - ); - } - } +function buildMcpMethodMap(options: McpHandlerOptions): Record { + const methods: Record = {}; - if (options.prompts) { - for (const prompt of options.prompts) { - server.registerPrompt( - prompt.name, - { - title: prompt.title, - description: prompt.description, - argsSchema: prompt.argsSchema, - }, - prompt.handler as any, - ); + // initialize + methods["initialize"] = () => { + const capabilities: Record = {}; + if (options.tools?.length) { + capabilities.tools = {}; } - } + if (options.resources?.length) { + capabilities.resources = {}; + } + if (options.prompts?.length) { + capabilities.prompts = {}; + } + return { + protocolVersion: MCP_PROTOCOL_VERSION, + serverInfo: { name: options.name, version: options.version }, + capabilities, + }; + }; + + // ping + methods["ping"] = () => ({}); + + // notifications/initialized (no-op, handled as notification by JSON-RPC layer) + methods["notifications/initialized"] = () => undefined; + + // tools + if (options.tools?.length) { + const tools = options.tools; + + methods["tools/list"] = () => ({ + 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.annotations !== undefined) entry.annotations = tool.annotations; + return entry; + }), + }); - return server; -} + methods["tools/call"] = async (req: JsonRpcRequest, event: H3Event) => { + const params = req.params as Record | undefined; + const name = params?.name as string; + const args = (params?.arguments ?? {}) as Record; -export async function handleMcpRequest( - options: McpHandlerOptions, - event: H3Event, -): Promise { - const method = event.req.method; + const tool = tools.find((t) => t.name === name); + if (!tool) { + throw new HTTPError({ status: 404, message: `Tool not found: ${name}` }); + } - if (method === "DELETE") { - return new Response(null, { status: 200 }); + if (tool.inputSchema) { + return await (tool.handler as (args: Record, event: H3Event) => unknown)( + args, + event, + ); + } + return await (tool.handler as (event: H3Event) => unknown)(event); + }; } - if (method !== "POST") { - return new Response("Method not allowed", { - status: 405, - headers: { allow: "POST, DELETE" }, + // resources + if (options.resources?.length) { + const resources = options.resources; + + methods["resources/list"] = () => ({ + 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; + return entry; + }), }); - } - const server = await createMcpServer(options); - const transport = new H3McpTransport(); + methods["resources/read"] = async (req: JsonRpcRequest, event: H3Event) => { + const params = req.params as Record | undefined; + const uriStr = params?.uri as string; + const uri = new URL(uriStr); - await server.connect(transport); + const resource = resources.find((r) => r.uri === uri.toString()); + if (!resource) { + throw new HTTPError({ status: 404, message: `Resource not found: ${uriStr}` }); + } - try { - const body = (await readBody(event)) as JSONRPCMessage | JSONRPCMessage[]; - const isBatch = Array.isArray(body); - const responses = await transport.processRequest(body); + return await resource.handler(uri, event); + }; + } - await server.close(); + // prompts + if (options.prompts?.length) { + const prompts = options.prompts; + + methods["prompts/list"] = () => ({ + 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; + }), + }); - if (responses.length === 0) { - return new Response(null, { status: 202 }); - } + methods["prompts/get"] = async (req: JsonRpcRequest, event: H3Event) => { + const params = req.params as Record | undefined; + const name = params?.name as string; + const args = (params?.arguments ?? {}) as Record; - const responseBody = isBatch ? JSON.stringify(responses) : JSON.stringify(responses[0]); + const prompt = prompts.find((p) => p.name === name); + if (!prompt) { + throw new HTTPError({ status: 404, message: `Prompt not found: ${name}` }); + } - return new Response(responseBody, { - status: 200, - headers: { "content-type": "application/json" }, - }); - } catch (error) { - await server.close(); - throw error; + 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 index 5381fb7cd..142fb024c 100644 --- a/src/utils/mcp.ts +++ b/src/utils/mcp.ts @@ -1,119 +1,332 @@ -import type { - CallToolResult, - GetPromptResult, - ReadResourceResult, - ToolAnnotations, - ServerRequest, - ServerNotification, -} from "@modelcontextprotocol/sdk/types.js"; -import type { RequestHandlerExtra } from "@modelcontextprotocol/sdk/shared/protocol.js"; -import type { ResourceTemplate, ResourceMetadata } from "@modelcontextprotocol/sdk/server/mcp.js"; -import type { - ZodRawShapeCompat, - ShapeOutput, -} from "@modelcontextprotocol/sdk/server/zod-compat.js"; - import { defineHandler } from "../handler.ts"; import { handleMcpRequest } from "./internal/mcp.ts"; import type { H3Event } from "../event.ts"; import type { EventHandler } from "../types/handler.ts"; -// --- tool types --- +// --- 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 --- -export type McpToolCallback = - Args extends ZodRawShapeCompat +/** + * 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: ShapeOutput, - extra: RequestHandlerExtra, - ) => CallToolResult | Promise - : ( - extra: RequestHandlerExtra, - ) => CallToolResult | Promise; + 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 ZodRawShapeCompat | undefined = undefined, - OutputSchema extends ZodRawShapeCompat | undefined = undefined, + InputSchema extends Record | undefined = undefined, > { name: string; title?: string; description?: string; inputSchema?: InputSchema; - outputSchema?: OutputSchema; - annotations?: ToolAnnotations; + annotations?: McpToolAnnotations; handler: McpToolCallback; } -// --- resource types --- +// --- MCP resource types --- +/** + * A function that handles reading an MCP resource. + */ export type McpResourceCallback = ( uri: URL, - extra: RequestHandlerExtra, -) => ReadResourceResult | Promise; - -export type McpResourceTemplateCallback = ( - uri: URL, - variables: Record, - extra: RequestHandlerExtra, -) => ReadResourceResult | Promise; + event: H3Event, +) => McpReadResourceResult | Promise; +/** + * MCP resource definition. + */ export interface McpResourceDefinition { name: string; title?: string; description?: string; - uri: string | ResourceTemplate; - metadata?: ResourceMetadata; - handler: McpResourceCallback | McpResourceTemplateCallback; + uri: string; + mimeType?: string; + handler: McpResourceCallback; } -// --- prompt types --- +// --- MCP prompt types --- -export type McpPromptCallback = - Args extends ZodRawShapeCompat - ? ( - args: ShapeOutput, - extra: RequestHandlerExtra, - ) => GetPromptResult | Promise - : ( - extra: RequestHandlerExtra, - ) => GetPromptResult | Promise; - -export interface McpPromptDefinition { +/** + * 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; - argsSchema?: Args; - handler: McpPromptCallback; + 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 --- +/** + * Options for `defineMcpHandler`. + */ export interface McpHandlerOptions { name: string; version: string; - tools?: McpToolDefinition[]; + tools?: McpToolDefinition[]; resources?: McpResourceDefinition[]; - prompts?: McpPromptDefinition[]; + prompts?: McpPromptDefinition[]; } // --- definition helpers --- /** - * Define an MCP tool. + * 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 ZodRawShapeCompat | undefined = undefined, - const OutputSchema extends ZodRawShapeCompat | undefined = undefined, ->( - definition: McpToolDefinition, -): McpToolDefinition { + const InputSchema extends Record | undefined = undefined, +>(definition: McpToolDefinition): McpToolDefinition { return definition; } /** - * Define an MCP resource. + * 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 */ @@ -122,18 +335,75 @@ export function defineMcpResource(definition: McpResourceDefinition): McpResourc } /** - * Define an MCP prompt. + * 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 { +export function defineMcpPrompt(definition: McpPromptDefinition): McpPromptDefinition { return definition; } /** - * Define an MCP event handler. + * 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 */ diff --git a/test/mcp.test.ts b/test/mcp.test.ts index a78fcd068..b8ab8b032 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -1,5 +1,4 @@ import { describe, it, expect, beforeEach } from "vitest"; -import { z } from "zod/v4"; import { defineMcpTool, defineMcpResource, @@ -28,14 +27,18 @@ describe("defineMcpTool", () => { it("should preserve inputSchema", () => { const tool = defineMcpTool({ name: "with-schema", - inputSchema: { message: z.string() }, + inputSchema: { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, handler: async ({ message }) => ({ - content: [{ type: "text" as const, text: message }], + content: [{ type: "text" as const, text: message as string }], }), }); expect(tool.name).toBe("with-schema"); expect(tool.inputSchema).toBeDefined(); - expect(tool.inputSchema!.message).toBeDefined(); + expect(tool.inputSchema!.type).toBe("object"); }); }); @@ -76,21 +79,21 @@ describe("defineMcpPrompt", () => { expect(prompt.handler).toBe(handler); }); - it("should preserve argsSchema", () => { + it("should preserve args", () => { const prompt = defineMcpPrompt({ name: "with-args", - argsSchema: { name: z.string() }, - handler: async ({ name }) => ({ + args: [{ name: "name", required: true }], + handler: async (args: Record) => ({ messages: [ { role: "user" as const, - content: { type: "text" as const, text: `Hello ${name}!` }, + content: { type: "text" as const, text: `Hello ${args.name}!` }, }, ], }), }); - expect(prompt.argsSchema).toBeDefined(); - expect(prompt.argsSchema!.name).toBeDefined(); + expect(prompt.args).toBeDefined(); + expect(prompt.args![0].name).toBe("name"); }); }); @@ -100,9 +103,13 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { const echoTool = defineMcpTool({ name: "echo", description: "Echo back a message", - inputSchema: { message: z.string() }, + inputSchema: { + type: "object", + properties: { message: { type: "string" } }, + required: ["message"], + }, handler: async ({ message }) => ({ - content: [{ type: "text" as const, text: message }], + content: [{ type: "text" as const, text: message as string }], }), }); @@ -118,7 +125,7 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { name: "readme", uri: "file:///readme", description: "Project README", - handler: async (uri: any) => ({ + handler: async (uri) => ({ contents: [{ uri: uri.toString(), text: "# My Project\nHello world" }], }), }); @@ -126,12 +133,12 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { const greetPrompt = defineMcpPrompt({ name: "greet", description: "Generate a greeting", - argsSchema: { name: z.string() }, - handler: async ({ name }) => ({ + args: [{ name: "name", required: true }], + handler: async (args: Record) => ({ messages: [ { role: "user" as const, - content: { type: "text" as const, text: `Hello ${name}!` }, + content: { type: "text" as const, text: `Hello ${args.name}!` }, }, ], }), @@ -185,13 +192,6 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { }); it("should handle tools/list", async () => { - // First initialize - await jsonRpc("initialize", { - protocolVersion: "2025-03-26", - capabilities: {}, - clientInfo: { name: "test-client", version: "1.0.0" }, - }); - const res = await jsonRpc("tools/list", {}, 2); expect(res.status).toBe(200); const body = await res.json(); @@ -204,12 +204,6 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { }); it("should handle tools/call", async () => { - await jsonRpc("initialize", { - protocolVersion: "2025-03-26", - capabilities: {}, - clientInfo: { name: "test-client", version: "1.0.0" }, - }); - const res = await jsonRpc( "tools/call", { name: "echo", arguments: { message: "hello world" } }, @@ -221,12 +215,6 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { }); it("should handle tools/call without arguments", async () => { - await jsonRpc("initialize", { - protocolVersion: "2025-03-26", - capabilities: {}, - clientInfo: { name: "test-client", version: "1.0.0" }, - }); - const res = await jsonRpc("tools/call", { name: "greet" }, 2); expect(res.status).toBe(200); const body = await res.json(); @@ -234,12 +222,6 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { }); it("should handle resources/list", async () => { - await jsonRpc("initialize", { - protocolVersion: "2025-03-26", - capabilities: {}, - clientInfo: { name: "test-client", version: "1.0.0" }, - }); - const res = await jsonRpc("resources/list", {}, 2); expect(res.status).toBe(200); const body = await res.json(); @@ -250,12 +232,6 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { }); it("should handle resources/read", async () => { - await jsonRpc("initialize", { - protocolVersion: "2025-03-26", - capabilities: {}, - clientInfo: { name: "test-client", version: "1.0.0" }, - }); - const res = await jsonRpc("resources/read", { uri: "file:///readme" }, 2); expect(res.status).toBe(200); const body = await res.json(); @@ -264,12 +240,6 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { }); it("should handle prompts/list", async () => { - await jsonRpc("initialize", { - protocolVersion: "2025-03-26", - capabilities: {}, - clientInfo: { name: "test-client", version: "1.0.0" }, - }); - const res = await jsonRpc("prompts/list", {}, 2); expect(res.status).toBe(200); const body = await res.json(); @@ -279,12 +249,6 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { }); it("should handle prompts/get", async () => { - await jsonRpc("initialize", { - protocolVersion: "2025-03-26", - capabilities: {}, - clientInfo: { name: "test-client", version: "1.0.0" }, - }); - const res = await jsonRpc("prompts/get", { name: "greet", arguments: { name: "World" } }, 2); expect(res.status).toBe(200); const body = await res.json(); @@ -293,12 +257,6 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { }); it("should handle ping", async () => { - await jsonRpc("initialize", { - protocolVersion: "2025-03-26", - capabilities: {}, - clientInfo: { name: "test-client", version: "1.0.0" }, - }); - const res = await jsonRpc("ping", {}, 2); expect(res.status).toBe(200); const body = await res.json(); From 9a80fb660a7d426590459df415bd9508983180fe Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 27 Feb 2026 22:15:46 +0100 Subject: [PATCH 4/6] update lock --- pnpm-lock.yaml | 203 ------------------------------------------------- 1 file changed, 203 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f563b12f..9640e728b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,9 +24,6 @@ importers: '@mitata/counters': specifier: ^0.0.8 version: 0.0.8 - '@modelcontextprotocol/sdk': - specifier: ^1.26.0 - version: 1.26.0(zod@4.3.6) '@types/connect': specifier: ^3.4.38 version: 3.4.38 @@ -362,12 +359,6 @@ packages: resolution: {integrity: sha512-JdsfSUVeWDP8klYL4y4C4Fae0nAv2V/2W+gHhdiuktyKGZvbSZfJpsk4loakPhTtxt91KHdDroXCCZcFIJrfYQ==} engines: {node: '>=20.0.0'} - '@hono/node-server@1.19.9': - resolution: {integrity: sha512-vHL6w3ecZsky+8P5MD+eFfaGTyCeOHUIFYMGpQGbrBTSmNNoxv0if69rEZ5giu36weC5saFuznL411gRX7bJDw==} - engines: {node: '>=18.14.1'} - peerDependencies: - hono: ^4 - '@jridgewell/gen-mapping@0.3.13': resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==} @@ -384,16 +375,6 @@ packages: '@mitata/counters@0.0.8': resolution: {integrity: sha512-f11w0Y1ETFlarDP7CePj8Z+y8Gv5Ax4gMxWsEwrqh0kH/YIY030Ezx5SUJeQg0YPTZ2OHKGcLG1oGJbIqHzaJA==} - '@modelcontextprotocol/sdk@1.26.0': - resolution: {integrity: sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg==} - engines: {node: '>=18'} - peerDependencies: - '@cfworker/json-schema': ^4.1.1 - zod: ^3.25 || ^4.0 - peerDependenciesMeta: - '@cfworker/json-schema': - optional: true - '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} @@ -1239,17 +1220,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - ajv-formats@3.0.1: - resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} - peerDependencies: - ajv: ^8.0.0 - peerDependenciesMeta: - ajv: - optional: true - - ajv@8.17.1: - resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} - array-find-index@1.0.2: resolution: {integrity: sha512-M1HQyIXcBGtVywBt8WVdim+lrNaK7VHp99Qt5pSNziXznKHViIBbXWtfRTpEFpF/c4FdfxNAsCCwPp5phBYJtw==} engines: {node: '>=0.10.0'} @@ -1381,14 +1351,6 @@ packages: resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} engines: {node: '>=18'} - cors@2.8.6: - resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} - engines: {node: '>= 0.10'} - - cross-spawn@7.0.6: - resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} - engines: {node: '>= 8'} - crossws@0.4.4: resolution: {integrity: sha512-w6c4OdpRNnudVmcgr7brb/+/HmYjMQvYToO/oTrprTwxRUiom3LYWU1PMWuD006okbUWpII1Ea9/+kwpUfmyRg==} peerDependencies: @@ -1524,14 +1486,6 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} - eventsource-parser@3.0.6: - resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} - engines: {node: '>=18.0.0'} - - eventsource@3.0.7: - resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} - engines: {node: '>=18.0.0'} - exact-mirror@0.2.2: resolution: {integrity: sha512-CrGe+4QzHZlnrXZVlo/WbUZ4qQZq8C0uATQVGVgXIrNXgHDBBNFD1VRfssRA2C9t3RYvh3MadZSdg2Wy7HBoQA==} peerDependencies: @@ -1544,12 +1498,6 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} - express-rate-limit@8.2.1: - resolution: {integrity: sha512-PCZEIEIxqwhzw4KF0n7QF4QqruVTcF73O5kFKUnGOyjbCCgizBBiFaYpd/fnBLUMPw/BWw9OsiN7GgrNYr7j6g==} - engines: {node: '>= 16'} - peerDependencies: - express: '>= 4.11' - express@5.2.1: resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} engines: {node: '>= 18'} @@ -1560,12 +1508,6 @@ packages: fast-decode-uri-component@1.0.1: resolution: {integrity: sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==} - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - - fast-uri@3.1.0: - resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} - fastest-levenshtein@1.0.16: resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} engines: {node: '>= 4.9.1'} @@ -1682,10 +1624,6 @@ packages: inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} - ip-address@10.0.1: - resolution: {integrity: sha512-NWv9YLW4PoW2B7xtzaS3NCot75m6nK7Icdv0o3lfMceJVRfSoQwqD4wEH5rLwoKJwUiZ/rfpiVBhnaF0FK4HoA==} - engines: {node: '>= 12'} - ipaddr.js@1.9.1: resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} engines: {node: '>= 0.10'} @@ -1715,9 +1653,6 @@ packages: resolution: {integrity: sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==} engines: {node: '>=16'} - isexe@2.0.0: - resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} - istanbul-lib-coverage@3.2.2: resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} engines: {node: '>=8'} @@ -1734,9 +1669,6 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true - jose@6.1.3: - resolution: {integrity: sha512-0TpaTfihd4QMNwrz/ob2Bp7X04yuxJkjRGi4aKmOqwhov54i6u79oCv7T+C7lo70MKH6BesI3vscD1yb/yzKXQ==} - js-tokens@10.0.0: resolution: {integrity: sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==} @@ -1745,12 +1677,6 @@ packages: engines: {node: '>=6'} hasBin: true - json-schema-traverse@1.0.0: - resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} - - json-schema-typed@8.0.2: - resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} - knitwork@1.3.0: resolution: {integrity: sha512-4LqMNoONzR43B1W0ek0fhXMsDNW/zxa1NdFAVMY+k28pgZLovR4G3PB5MrpTxCy1QaZCqNoiaKPr5w5qZHfSNw==} @@ -1838,10 +1764,6 @@ packages: engines: {node: '>=18'} hasBin: true - object-assign@4.1.1: - resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} - engines: {node: '>=0.10.0'} - object-inspect@1.13.4: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} @@ -1904,10 +1826,6 @@ packages: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} - path-key@3.1.1: - resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} - engines: {node: '>=8'} - path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} @@ -1924,10 +1842,6 @@ packages: resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} engines: {node: '>=12'} - pkce-challenge@5.0.1: - resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} - engines: {node: '>=16.20.0'} - pkg-types@1.3.1: resolution: {integrity: sha512-/Jm5M4RvtBFVkKWRu2BLUTNP8/M2a+UwuAX+ae4770q1qVGtfjG+WTCupoZixokjmHiry8uI+dlY8KXYV5HVVQ==} @@ -1977,10 +1891,6 @@ packages: resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} engines: {node: '>= 20.19.0'} - require-from-string@2.0.2: - resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} - engines: {node: '>=0.10.0'} - resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} @@ -2055,14 +1965,6 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} - shebang-command@2.0.0: - resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} - engines: {node: '>=8'} - - shebang-regex@3.0.0: - resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} - engines: {node: '>=8'} - side-channel-list@1.0.0: resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==} engines: {node: '>= 0.4'} @@ -2277,11 +2179,6 @@ packages: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} - which@2.0.2: - resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} - engines: {node: '>= 8'} - hasBin: true - why-is-node-running@2.3.0: resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} engines: {node: '>=8'} @@ -2306,11 +2203,6 @@ packages: resolution: {integrity: sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==} engines: {node: '>=18'} - zod-to-json-schema@3.25.1: - resolution: {integrity: sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==} - peerDependencies: - zod: ^3.25 || ^4 - zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} @@ -2459,10 +2351,6 @@ snapshots: - bufferutil - utf-8-validate - '@hono/node-server@1.19.9(hono@4.12.3)': - dependencies: - hono: 4.12.3 - '@jridgewell/gen-mapping@0.3.13': dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -2479,28 +2367,6 @@ snapshots: '@mitata/counters@0.0.8': {} - '@modelcontextprotocol/sdk@1.26.0(zod@4.3.6)': - dependencies: - '@hono/node-server': 1.19.9(hono@4.12.3) - ajv: 8.17.1 - ajv-formats: 3.0.1(ajv@8.17.1) - content-type: 1.0.5 - cors: 2.8.6 - cross-spawn: 7.0.6 - eventsource: 3.0.7 - eventsource-parser: 3.0.6 - express: 5.2.1 - express-rate-limit: 8.2.1(express@5.2.1) - hono: 4.12.3 - jose: 6.1.3 - json-schema-typed: 8.0.2 - pkce-challenge: 5.0.1 - raw-body: 3.0.2 - zod: 4.3.6 - zod-to-json-schema: 3.25.1(zod@4.3.6) - transitivePeerDependencies: - - supports-color - '@napi-rs/wasm-runtime@1.1.1': dependencies: '@emnapi/core': 1.8.1 @@ -3040,17 +2906,6 @@ snapshots: acorn@8.16.0: {} - ajv-formats@3.0.1(ajv@8.17.1): - optionalDependencies: - ajv: 8.17.1 - - ajv@8.17.1: - dependencies: - fast-deep-equal: 3.1.3 - fast-uri: 3.1.0 - json-schema-traverse: 1.0.0 - require-from-string: 2.0.2 - array-find-index@1.0.2: {} assertion-error@2.0.1: {} @@ -3214,17 +3069,6 @@ snapshots: cookie@1.1.1: {} - cors@2.8.6: - dependencies: - object-assign: 4.1.1 - vary: 1.1.2 - - cross-spawn@7.0.6: - dependencies: - path-key: 3.1.1 - shebang-command: 2.0.0 - which: 2.0.2 - crossws@0.4.4(srvx@0.11.8): optionalDependencies: srvx: 0.11.8 @@ -3339,23 +3183,12 @@ snapshots: etag@1.8.1: {} - eventsource-parser@3.0.6: {} - - eventsource@3.0.7: - dependencies: - eventsource-parser: 3.0.6 - exact-mirror@0.2.2(@sinclair/typebox@0.34.41): optionalDependencies: '@sinclair/typebox': 0.34.41 expect-type@1.3.0: {} - express-rate-limit@8.2.1(express@5.2.1): - dependencies: - express: 5.2.1 - ip-address: 10.0.1 - express@5.2.1: dependencies: accepts: 2.0.0 @@ -3393,10 +3226,6 @@ snapshots: fast-decode-uri-component@1.0.1: {} - fast-deep-equal@3.1.3: {} - - fast-uri@3.1.0: {} - fastest-levenshtein@1.0.16: {} fdir@6.5.0(picomatch@4.0.3): @@ -3530,8 +3359,6 @@ snapshots: inherits@2.0.4: {} - ip-address@10.0.1: {} - ipaddr.js@1.9.1: {} is-docker@3.0.0: {} @@ -3552,8 +3379,6 @@ snapshots: dependencies: is-inside-container: 1.0.0 - isexe@2.0.0: {} - istanbul-lib-coverage@3.2.2: {} istanbul-lib-report@3.0.1: @@ -3569,16 +3394,10 @@ snapshots: jiti@2.6.1: {} - jose@6.1.3: {} - js-tokens@10.0.0: {} jsesc@3.1.0: {} - json-schema-traverse@1.0.0: {} - - json-schema-typed@8.0.2: {} - knitwork@1.3.0: {} lodash.deburr@4.1.0: {} @@ -3650,8 +3469,6 @@ snapshots: pathe: 2.0.3 tinyexec: 1.0.2 - object-assign@4.1.1: {} - object-inspect@1.13.4: {} obug@2.1.1: {} @@ -3787,8 +3604,6 @@ snapshots: parseurl@1.3.3: {} - path-key@3.1.1: {} - path-to-regexp@8.3.0: {} pathe@2.0.3: {} @@ -3799,8 +3614,6 @@ snapshots: picomatch@4.0.3: {} - pkce-challenge@5.0.1: {} - pkg-types@1.3.1: dependencies: confbox: 0.1.8 @@ -3858,8 +3671,6 @@ snapshots: readdirp@5.0.0: {} - require-from-string@2.0.2: {} - resolve-pkg-maps@1.0.0: {} rolldown-plugin-dts@0.22.2(@typescript/native-preview@7.0.0-dev.20260227.1)(rolldown@1.0.0-rc.6)(typescript@5.9.3): @@ -3993,12 +3804,6 @@ snapshots: setprototypeof@1.2.0: {} - shebang-command@2.0.0: - dependencies: - shebang-regex: 3.0.0 - - shebang-regex@3.0.0: {} - side-channel-list@1.0.0: dependencies: es-errors: 1.3.0 @@ -4181,10 +3986,6 @@ snapshots: whatwg-mimetype@3.0.0: {} - which@2.0.2: - dependencies: - isexe: 2.0.0 - why-is-node-running@2.3.0: dependencies: siginfo: 2.0.0 @@ -4198,8 +3999,4 @@ snapshots: dependencies: is-wsl: 3.1.1 - zod-to-json-schema@3.25.1(zod@4.3.6): - dependencies: - zod: 4.3.6 - zod@4.3.6: {} From e4258224a6cd3439392ebd09c5d7d46cba0c869d Mon Sep 17 00:00:00 2001 From: Pooya Parsa Date: Fri, 27 Feb 2026 22:28:47 +0100 Subject: [PATCH 5/6] up --- AGENTS.md | 26 ++++ src/index.ts | 1 + src/utils/internal/mcp.ts | 218 +++++++++++++++++---------- src/utils/mcp.ts | 25 +++- test/mcp.test.ts | 304 +++++++++++++++++++++++++++++++++++++- 5 files changed, 488 insertions(+), 86 deletions(-) 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/src/index.ts b/src/index.ts index e07d3294c..a7205cbd5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -236,6 +236,7 @@ export { type McpPromptCallback, type McpPromptArgument, type McpPromptDefinition, + type MaybeLazy, type McpHandlerOptions, defineMcpHandler, defineMcpTool, diff --git a/src/utils/internal/mcp.ts b/src/utils/internal/mcp.ts index 4111a34f5..8d1709128 100644 --- a/src/utils/internal/mcp.ts +++ b/src/utils/internal/mcp.ts @@ -1,13 +1,37 @@ import type { H3Event } from "../../event.ts"; import type { JsonRpcMethod, JsonRpcRequest } from "../json-rpc.ts"; -import type { McpHandlerOptions } from "../mcp.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-03-26"; +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: McpHandlerOptions, + options: McpResolvedOptions, event: H3Event, ): Promise { const method = event.req.method; @@ -23,6 +47,13 @@ export async function handleMcpRequest( }); } + 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); @@ -50,26 +81,57 @@ export async function handleMcpRequest( // --- Internal helpers --- -function buildMcpMethodMap(options: McpHandlerOptions): Record { +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"] = () => { + methods["initialize"] = async () => { const capabilities: Record = {}; - if (options.tools?.length) { + const [tools, resources, prompts] = await Promise.all([ + options.tools(), + options.resources(), + options.prompts(), + ]); + if (tools?.length) { capabilities.tools = {}; } - if (options.resources?.length) { + if (resources?.length) { capabilities.resources = {}; } - if (options.prompts?.length) { + if (prompts?.length) { capabilities.prompts = {}; } - return { + 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: { name: options.name, version: options.version }, + serverInfo, capabilities, }; + if (options.instructions !== undefined) { + result.instructions = options.instructions; + } + return result; }; // ping @@ -79,48 +141,48 @@ function buildMcpMethodMap(options: McpHandlerOptions): Record undefined; // tools - if (options.tools?.length) { - const tools = options.tools; - - methods["tools/list"] = () => ({ - tools: tools.map((tool) => { + 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 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 - if (options.resources?.length) { - const resources = options.resources; + 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}` }); + } - methods["resources/list"] = () => ({ - resources: resources.map((r) => { + 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, @@ -128,30 +190,31 @@ function buildMcpMethodMap(options: McpHandlerOptions): Record { - const params = req.params as Record | undefined; - const uriStr = params?.uri as string; - const uri = new URL(uriStr); + 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}` }); - } + 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); - }; - } + return await resource.handler(uri, event); + }; // prompts - if (options.prompts?.length) { - const prompts = options.prompts; - - methods["prompts/list"] = () => ({ - prompts: prompts.map((p) => { + methods["prompts/list"] = async () => { + const prompts = await options.prompts(); + return { + prompts: (prompts ?? []).map((p) => { const entry: Record = { name: p.name, }; @@ -160,27 +223,28 @@ function buildMcpMethodMap(options: McpHandlerOptions): Record { - 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); }; - } + }; + + 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/mcp.ts b/src/utils/mcp.ts index 142fb024c..d6d31dc12 100644 --- a/src/utils/mcp.ts +++ b/src/utils/mcp.ts @@ -1,5 +1,5 @@ import { defineHandler } from "../handler.ts"; -import { handleMcpRequest } from "./internal/mcp.ts"; +import { handleMcpRequest, resolveMcpOptions } from "./internal/mcp.ts"; import type { H3Event } from "../event.ts"; import type { EventHandler } from "../types/handler.ts"; @@ -173,6 +173,7 @@ export interface McpToolDefinition< title?: string; description?: string; inputSchema?: InputSchema; + outputSchema?: Record; annotations?: McpToolAnnotations; handler: McpToolCallback; } @@ -196,6 +197,7 @@ export interface McpResourceDefinition { description?: string; uri: string; mimeType?: string; + size?: number; handler: McpResourceCallback; } @@ -259,15 +261,23 @@ export type McpPromptDefinition = McpPromptDefinitionWithArgs | McpPromptDefinit // --- 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; - tools?: McpToolDefinition[]; - resources?: McpResourceDefinition[]; - prompts?: McpPromptDefinition[]; + title?: string; + instructions?: string; + tools?: MaybeLazy>[]; + resources?: MaybeLazy[]; + prompts?: MaybeLazy[]; } // --- definition helpers --- @@ -410,8 +420,11 @@ export function defineMcpPrompt(definition: McpPromptDefinition): McpPromptDefin 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 resolvedOptions = typeof options === "function" ? options(event) : options; - return handleMcpRequest(resolvedOptions, 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 index b8ab8b032..5828d27d9 100644 --- a/test/mcp.test.ts +++ b/test/mcp.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; import { defineMcpTool, defineMcpResource, @@ -177,7 +177,7 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { it("should handle initialize", async () => { const res = await jsonRpc("initialize", { - protocolVersion: "2025-03-26", + protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test-client", version: "1.0.0" }, }); @@ -186,6 +186,7 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { 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(); @@ -278,6 +279,157 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { 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", @@ -296,7 +448,7 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { id: 1, method: "initialize", params: { - protocolVersion: "2025-03-26", + protocolVersion: "2025-06-18", capabilities: {}, clientInfo: { name: "test-client", version: "1.0.0" }, }, @@ -309,3 +461,149 @@ describeMatrix("defineMcpHandler", (t, { it, expect }) => { 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(); + }); +}); From 266536324a2f9142ba9cd0af6eb9ef511ffeb70f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 21:29:27 +0000 Subject: [PATCH 6/6] chore: apply automated updates --- docs/2.utils/6.mcp.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/2.utils/6.mcp.md b/docs/2.utils/6.mcp.md index 5677f3434..5c526be3a 100644 --- a/docs/2.utils/6.mcp.md +++ b/docs/2.utils/6.mcp.md @@ -56,7 +56,9 @@ const greetPrompt = defineMcpPrompt({ description: "Generate a greeting", args: [{ name: "name", required: true }], handler: async (args, event) => ({ - messages: [{ role: "user", content: { type: "text", text: `Hello ${args.name}!` } }], + messages: [ + { role: "user", content: { type: "text", text: `Hello ${args.name}!` } }, + ], }), }); ``` @@ -69,7 +71,9 @@ const helpPrompt = defineMcpPrompt({ name: "help", description: "Show help information", handler: async (event) => ({ - messages: [{ role: "user", content: { type: "text", text: "How can I help?" } }], + messages: [ + { role: "user", content: { type: "text", text: "How can I help?" } }, + ], }), }); ``` @@ -137,7 +141,7 @@ 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. +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()`