diff --git a/examples/mcp.mjs b/examples/mcp.mjs new file mode 100644 index 00000000..0fecdc55 --- /dev/null +++ b/examples/mcp.mjs @@ -0,0 +1,101 @@ +import { z } from "zod"; +import { H3, serve, defineMcpHandler, defineMcpTool, defineMcpResource, defineMcpPrompt } from "h3"; + +export const app = new H3(); + +// --- Define MCP tools --- + +const echoTool = defineMcpTool({ + name: "echo", + description: "Echo back a message", + inputSchema: { message: z.string().describe("The message to echo") }, + handler: async ({ message }) => ({ + content: [{ type: "text", text: message }], + }), +}); + +const calculatorTool = defineMcpTool({ + name: "calculator", + description: "Perform basic math operations", + inputSchema: { + operation: z.enum(["add", "subtract", "multiply", "divide"]), + a: z.number().describe("First number"), + b: z.number().describe("Second number"), + }, + handler: async ({ operation, a, b }) => { + let result; + switch (operation) { + case "add": + result = a + b; + break; + case "subtract": + result = a - b; + break; + case "multiply": + result = a * b; + break; + case "divide": + result = b !== 0 ? a / b : "Error: Division by zero"; + break; + } + return { + content: [{ type: "text", text: JSON.stringify({ operation, a, b, result }, null, 2) }], + }; + }, +}); + +// --- Define MCP resources --- + +const aboutResource = defineMcpResource({ + name: "about", + uri: "file:///about", + description: "Information about this MCP server", + handler: async (uri) => ({ + contents: [ + { + uri: uri.toString(), + text: "This is an example MCP server built with h3.", + }, + ], + }), +}); + +// --- Define MCP prompts --- + +const summarizePrompt = defineMcpPrompt({ + name: "summarize", + description: "Generate a prompt to summarize text", + argsSchema: { + text: z.string().describe("The text to summarize"), + }, + handler: async ({ text }) => ({ + messages: [ + { + role: "user", + content: { + type: "text", + text: `Please summarize the following text:\n\n${text}`, + }, + }, + ], + }), +}); + +// --- Create the MCP handler --- + +app.all( + "/mcp", + defineMcpHandler({ + name: "h3-mcp-example", + version: "1.0.0", + tools: [echoTool, calculatorTool], + resources: [aboutResource], + prompts: [summarizePrompt], + }), +); + +// --- Landing page --- + +app.get("/", () => "MCP server running at /mcp"); + +serve(app); diff --git a/package.json b/package.json index 2d3623cf..140cc0d8 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", @@ -92,11 +93,15 @@ "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/pnpm-lock.yaml b/pnpm-lock.yaml index f700934c..c86f805d 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/handler.ts b/src/handler.ts index c53f1aaf..a591279d 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/index.ts b/src/index.ts index 31d967a7..c00c843b 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 00000000..d06f13cb --- /dev/null +++ b/src/utils/internal/mcp.ts @@ -0,0 +1,168 @@ +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 00000000..5381fb7c --- /dev/null +++ b/src/utils/mcp.ts @@ -0,0 +1,147 @@ +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 + ? ( + 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 + ? ( + args: ShapeOutput, + extra: RequestHandlerExtra, + ) => GetPromptResult | Promise + : ( + extra: RequestHandlerExtra, + ) => GetPromptResult | Promise; + +export interface McpPromptDefinition { + 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( + 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 00000000..ea848a84 --- /dev/null +++ b/test/mcp.test.ts @@ -0,0 +1,353 @@ +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"); + }); +});