Skip to content

Commit e425822

Browse files
committed
up
1 parent 9a80fb6 commit e425822

5 files changed

Lines changed: 488 additions & 86 deletions

File tree

AGENTS.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ src/
8080
│ ├── proxy.ts # proxy, proxyRequest, fetchWithEvent
8181
│ ├── ws.ts # defineWebSocketHandler, defineWebSocket
8282
│ ├── json-rpc.ts # defineJsonRpcHandler, defineJsonRpcWebSocketHandler
83+
│ ├── mcp.ts # defineMcpHandler, defineMcpTool, defineMcpResource, defineMcpPrompt
8384
│ ├── event-stream.ts # createEventStream (SSE)
8485
│ ├── static.ts # serveStatic
8586
│ ├── cache.ts # handleCacheHeaders
@@ -89,6 +90,7 @@ src/
8990
│ └── internal/ # Internal helpers (not exported)
9091
│ ├── auth.ts, body.ts, cors.ts, encoding.ts, ...
9192
│ ├── iron-crypto.ts # Session sealing crypto
93+
│ ├── mcp.ts # MCP internal handler logic (handleMcpRequest, resolveMcpOptions)
9294
│ ├── standard-schema.ts # Standard schema validation
9395
│ └── validate.ts
9496
├── _entries/ # Platform-specific entry points
@@ -226,6 +228,30 @@ h3/tracing → Tracing plugin
226228
| `srvx` | Server abstraction (multi-runtime) |
227229
| `crossws` | WebSocket abstraction (optional peer dep) |
228230

231+
## MCP (Model Context Protocol)
232+
233+
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"`).
234+
235+
### Architecture
236+
237+
- **Public API** (`src/utils/mcp.ts`): Types + `defineMcpHandler`, `defineMcpTool`, `defineMcpResource`, `defineMcpPrompt`
238+
- **Internal handler** (`src/utils/internal/mcp.ts`): `handleMcpRequest` processes HTTP → JSON-RPC, `resolveMcpOptions` handles lazy resolution
239+
- Built on top of `src/utils/json-rpc.ts` (`processJsonRpcBody`, `createMethodMap`)
240+
241+
### Key patterns
242+
243+
- **`MaybeLazy<T>`**: `tools`, `resources`, `prompts` accept `T | (() => T | Promise<T>)`. 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.
244+
- **`McpResolvedOptions`**: Internal type with pre-bound lazy resolvers. Created by `resolveMcpOptions()` and passed to `handleMcpRequest()`.
245+
- `defineMcpHandler` supports both static options and `(event: H3Event) => McpHandlerOptions` for per-request config.
246+
247+
### MCP methods implemented
248+
249+
`initialize`, `ping`, `notifications/initialized`, `tools/list`, `tools/call`, `resources/list`, `resources/read`, `prompts/list`, `prompts/get`
250+
251+
### Tests
252+
253+
`test/mcp.test.ts` — unit tests for define helpers + integration tests via `describeMatrix` (web + node).
254+
229255
## Best Practices for Contributing
230256

231257
- Prefer web standard APIs over runtime-specific ones

src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ export {
236236
type McpPromptCallback,
237237
type McpPromptArgument,
238238
type McpPromptDefinition,
239+
type MaybeLazy,
239240
type McpHandlerOptions,
240241
defineMcpHandler,
241242
defineMcpTool,

src/utils/internal/mcp.ts

Lines changed: 141 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,37 @@
11
import type { H3Event } from "../../event.ts";
22
import type { JsonRpcMethod, JsonRpcRequest } from "../json-rpc.ts";
3-
import type { McpHandlerOptions } from "../mcp.ts";
3+
import type { MaybeLazy, McpHandlerOptions } from "../mcp.ts";
4+
import type { McpToolDefinition, McpResourceDefinition, McpPromptDefinition } from "../mcp.ts";
45
import { processJsonRpcBody, createJsonRpcError, createMethodMap } from "../json-rpc.ts";
56
import { HTTPError } from "../../error.ts";
67

7-
const MCP_PROTOCOL_VERSION = "2025-03-26";
8+
const MCP_PROTOCOL_VERSION = "2025-06-18";
9+
const SUPPORTED_PROTOCOL_VERSIONS = new Set(["2025-06-18", "2025-03-26"]);
10+
11+
export interface McpResolvedOptions {
12+
name: string;
13+
version: string;
14+
title?: string;
15+
instructions?: string;
16+
tools: () => Promise<McpToolDefinition<any>[] | undefined>;
17+
resources: () => Promise<McpResourceDefinition[] | undefined>;
18+
prompts: () => Promise<McpPromptDefinition[] | undefined>;
19+
}
20+
21+
export function resolveMcpOptions(options: McpHandlerOptions): McpResolvedOptions {
22+
return {
23+
name: options.name,
24+
version: options.version,
25+
title: options.title,
26+
instructions: options.instructions,
27+
tools: _resolveLazyArray(options.tools),
28+
resources: _resolveLazyArray(options.resources),
29+
prompts: _resolveLazyArray(options.prompts),
30+
};
31+
}
832

933
export async function handleMcpRequest(
10-
options: McpHandlerOptions,
34+
options: McpResolvedOptions,
1135
event: H3Event,
1236
): Promise<Response> {
1337
const method = event.req.method;
@@ -23,6 +47,13 @@ export async function handleMcpRequest(
2347
});
2448
}
2549

50+
const protocolVersion = event.req.headers.get("mcp-protocol-version");
51+
if (protocolVersion && !SUPPORTED_PROTOCOL_VERSIONS.has(protocolVersion)) {
52+
return new Response(`Unsupported MCP protocol version: ${protocolVersion}`, {
53+
status: 400,
54+
});
55+
}
56+
2657
const methods = buildMcpMethodMap(options);
2758
const methodMap = createMethodMap(methods);
2859

@@ -50,26 +81,57 @@ export async function handleMcpRequest(
5081

5182
// --- Internal helpers ---
5283

53-
function buildMcpMethodMap(options: McpHandlerOptions): Record<string, JsonRpcMethod> {
84+
function _resolveLazyArray<T>(items: MaybeLazy<T>[] | undefined): () => Promise<T[] | undefined> {
85+
if (!items?.length) {
86+
return () => Promise.resolve(undefined);
87+
}
88+
let cached: Promise<T[]> | undefined;
89+
return () => {
90+
if (!cached) {
91+
cached = Promise.all(
92+
items.map((item) => (typeof item === "function" ? (item as () => T | Promise<T>)() : item)),
93+
);
94+
}
95+
return cached;
96+
};
97+
}
98+
99+
function buildMcpMethodMap(options: McpResolvedOptions): Record<string, JsonRpcMethod> {
54100
const methods: Record<string, JsonRpcMethod> = {};
55101

56102
// initialize
57-
methods["initialize"] = () => {
103+
methods["initialize"] = async () => {
58104
const capabilities: Record<string, unknown> = {};
59-
if (options.tools?.length) {
105+
const [tools, resources, prompts] = await Promise.all([
106+
options.tools(),
107+
options.resources(),
108+
options.prompts(),
109+
]);
110+
if (tools?.length) {
60111
capabilities.tools = {};
61112
}
62-
if (options.resources?.length) {
113+
if (resources?.length) {
63114
capabilities.resources = {};
64115
}
65-
if (options.prompts?.length) {
116+
if (prompts?.length) {
66117
capabilities.prompts = {};
67118
}
68-
return {
119+
const serverInfo: Record<string, string> = {
120+
name: options.name,
121+
version: options.version,
122+
};
123+
if (options.title !== undefined) {
124+
serverInfo.title = options.title;
125+
}
126+
const result: Record<string, unknown> = {
69127
protocolVersion: MCP_PROTOCOL_VERSION,
70-
serverInfo: { name: options.name, version: options.version },
128+
serverInfo,
71129
capabilities,
72130
};
131+
if (options.instructions !== undefined) {
132+
result.instructions = options.instructions;
133+
}
134+
return result;
73135
};
74136

75137
// ping
@@ -79,79 +141,80 @@ function buildMcpMethodMap(options: McpHandlerOptions): Record<string, JsonRpcMe
79141
methods["notifications/initialized"] = () => undefined;
80142

81143
// tools
82-
if (options.tools?.length) {
83-
const tools = options.tools;
84-
85-
methods["tools/list"] = () => ({
86-
tools: tools.map((tool) => {
144+
methods["tools/list"] = async () => {
145+
const tools = await options.tools();
146+
return {
147+
tools: (tools ?? []).map((tool) => {
87148
const entry: Record<string, unknown> = {
88149
name: tool.name,
89150
inputSchema: tool.inputSchema ?? { type: "object" },
90151
};
91152
if (tool.title !== undefined) entry.title = tool.title;
92153
if (tool.description !== undefined) entry.description = tool.description;
154+
if (tool.outputSchema !== undefined) entry.outputSchema = tool.outputSchema;
93155
if (tool.annotations !== undefined) entry.annotations = tool.annotations;
94156
return entry;
95157
}),
96-
});
97-
98-
methods["tools/call"] = async (req: JsonRpcRequest, event: H3Event) => {
99-
const params = req.params as Record<string, unknown> | undefined;
100-
const name = params?.name as string;
101-
const args = (params?.arguments ?? {}) as Record<string, unknown>;
102-
103-
const tool = tools.find((t) => t.name === name);
104-
if (!tool) {
105-
throw new HTTPError({ status: 404, message: `Tool not found: ${name}` });
106-
}
107-
108-
if (tool.inputSchema) {
109-
return await (tool.handler as (args: Record<string, unknown>, event: H3Event) => unknown)(
110-
args,
111-
event,
112-
);
113-
}
114-
return await (tool.handler as (event: H3Event) => unknown)(event);
115158
};
116-
}
159+
};
117160

118-
// resources
119-
if (options.resources?.length) {
120-
const resources = options.resources;
161+
methods["tools/call"] = async (req: JsonRpcRequest, event: H3Event) => {
162+
const tools = await options.tools();
163+
const params = req.params as Record<string, unknown> | undefined;
164+
const name = params?.name as string;
165+
const args = (params?.arguments ?? {}) as Record<string, unknown>;
166+
167+
const tool = tools?.find((t) => t.name === name);
168+
if (!tool) {
169+
throw new HTTPError({ status: 404, message: `Tool not found: ${name}` });
170+
}
121171

122-
methods["resources/list"] = () => ({
123-
resources: resources.map((r) => {
172+
if (tool.inputSchema) {
173+
return await (tool.handler as (args: Record<string, unknown>, event: H3Event) => unknown)(
174+
args,
175+
event,
176+
);
177+
}
178+
return await (tool.handler as (event: H3Event) => unknown)(event);
179+
};
180+
181+
// resources
182+
methods["resources/list"] = async () => {
183+
const resources = await options.resources();
184+
return {
185+
resources: (resources ?? []).map((r) => {
124186
const entry: Record<string, unknown> = {
125187
name: r.name,
126188
uri: r.uri,
127189
};
128190
if (r.title !== undefined) entry.title = r.title;
129191
if (r.description !== undefined) entry.description = r.description;
130192
if (r.mimeType !== undefined) entry.mimeType = r.mimeType;
193+
if (r.size !== undefined) entry.size = r.size;
131194
return entry;
132195
}),
133-
});
196+
};
197+
};
134198

135-
methods["resources/read"] = async (req: JsonRpcRequest, event: H3Event) => {
136-
const params = req.params as Record<string, unknown> | undefined;
137-
const uriStr = params?.uri as string;
138-
const uri = new URL(uriStr);
199+
methods["resources/read"] = async (req: JsonRpcRequest, event: H3Event) => {
200+
const resources = await options.resources();
201+
const params = req.params as Record<string, unknown> | undefined;
202+
const uriStr = params?.uri as string;
203+
const uri = new URL(uriStr);
139204

140-
const resource = resources.find((r) => r.uri === uri.toString());
141-
if (!resource) {
142-
throw new HTTPError({ status: 404, message: `Resource not found: ${uriStr}` });
143-
}
205+
const resource = resources?.find((r) => r.uri === uri.toString());
206+
if (!resource) {
207+
throw new HTTPError({ status: 404, message: `Resource not found: ${uriStr}` });
208+
}
144209

145-
return await resource.handler(uri, event);
146-
};
147-
}
210+
return await resource.handler(uri, event);
211+
};
148212

149213
// prompts
150-
if (options.prompts?.length) {
151-
const prompts = options.prompts;
152-
153-
methods["prompts/list"] = () => ({
154-
prompts: prompts.map((p) => {
214+
methods["prompts/list"] = async () => {
215+
const prompts = await options.prompts();
216+
return {
217+
prompts: (prompts ?? []).map((p) => {
155218
const entry: Record<string, unknown> = {
156219
name: p.name,
157220
};
@@ -160,27 +223,28 @@ function buildMcpMethodMap(options: McpHandlerOptions): Record<string, JsonRpcMe
160223
if (p.args?.length) entry.arguments = p.args;
161224
return entry;
162225
}),
163-
});
164-
165-
methods["prompts/get"] = async (req: JsonRpcRequest, event: H3Event) => {
166-
const params = req.params as Record<string, unknown> | undefined;
167-
const name = params?.name as string;
168-
const args = (params?.arguments ?? {}) as Record<string, string>;
169-
170-
const prompt = prompts.find((p) => p.name === name);
171-
if (!prompt) {
172-
throw new HTTPError({ status: 404, message: `Prompt not found: ${name}` });
173-
}
174-
175-
if (prompt.args?.length) {
176-
return await (prompt.handler as (args: Record<string, string>, event: H3Event) => unknown)(
177-
args,
178-
event,
179-
);
180-
}
181-
return await (prompt.handler as (event: H3Event) => unknown)(event);
182226
};
183-
}
227+
};
228+
229+
methods["prompts/get"] = async (req: JsonRpcRequest, event: H3Event) => {
230+
const prompts = await options.prompts();
231+
const params = req.params as Record<string, unknown> | undefined;
232+
const name = params?.name as string;
233+
const args = (params?.arguments ?? {}) as Record<string, string>;
234+
235+
const prompt = prompts?.find((p) => p.name === name);
236+
if (!prompt) {
237+
throw new HTTPError({ status: 404, message: `Prompt not found: ${name}` });
238+
}
239+
240+
if (prompt.args?.length) {
241+
return await (prompt.handler as (args: Record<string, string>, event: H3Event) => unknown)(
242+
args,
243+
event,
244+
);
245+
}
246+
return await (prompt.handler as (event: H3Event) => unknown)(event);
247+
};
184248

185249
return methods;
186250
}

0 commit comments

Comments
 (0)