typespec-http-mcp 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runtime.ts ADDED
@@ -0,0 +1,475 @@
1
+ import type { CallToolResult, McpServer, ServerContext } from "@modelcontextprotocol/server";
2
+ import type { ZodError, ZodType } from "zod";
3
+
4
+ /**
5
+ * **What generated code imports at run time -- and the only thing this package puts in its output.**
6
+ *
7
+ * **Kept deliberately tiny, and deliberately free of every TypeSpec import.** The main entry point
8
+ * is an emitter: it pulls in `@typespec/compiler` and exists at build time. This module is what an
9
+ * *application* touches, and making it reach the compiler would put a build-time dependency into a
10
+ * server's runtime graph for the sake of a few declarations. `test/packaging.test.ts` asserts the
11
+ * separation rather than trusting it.
12
+ *
13
+ * **Always emitted, never substituted.** `runtime-module` used to point the generated file at a
14
+ * module of the application's own, which made the application own a copy of the arm selection and
15
+ * the result rules below. Nothing it was for needs it now: the caller context is inferred from
16
+ * `deps.context`, and what a handler returns is the union of the responses the document declares.
17
+ */
18
+
19
+ /**
20
+ * The SDK types the generated signatures name, re-exported so emitted code has ONE specifier.
21
+ *
22
+ * **`export type`, not `export`.** Measured: `McpServer`, `ServerContext` and `CallToolResult` are
23
+ * type-only exports of `@modelcontextprotocol/server`; only `isCallToolResult` is a runtime value
24
+ * among them. A value re-export of any of these compiles and then fails at run time with
25
+ * `SyntaxError: The requested module does not provide an export named ...`.
26
+ */
27
+ export type { CallToolResult, McpServer, ServerContext };
28
+
29
+ /**
30
+ * One status an operation may answer with, and what the document publishes for it.
31
+ *
32
+ * **Declared here rather than imported from `typespec-http-zod`, deliberately.** Emitted output must
33
+ * never import this emitter or the library it runs -- a consumer installs both as devDependencies,
34
+ * so a runtime import of either is a broken install. `test/packaging.test.ts` asserts that. The
35
+ * emitted `schemas.gen.ts` declares the same shape locally, so it depends on no runtime at all.
36
+ *
37
+ * The cost is a second declaration of one shape, and `test/runtime/parity.test.ts` is what stops the
38
+ * two drifting: it grades this `armFor` against the library's own for every arm shape.
39
+ *
40
+ * **The arms arrive in OpenAPI's precedence order, so the FIRST match is the right one** -- an exact
41
+ * code, then a range, then the catch-all. `schema: undefined` is a statement rather than an absence:
42
+ * a response the document says carries no body has no validator.
43
+ */
44
+ export interface ResponseArm {
45
+ /** Every media type this response offers, including a single one. Absent where there is no body. */
46
+ readonly contentTypes?: readonly string[];
47
+ /**
48
+ * The headers this response declares, by the WIRE name the response sets. `optional` is the
49
+ * document's `required: false`. Absent where the response declares none.
50
+ */
51
+ readonly headers?: readonly { readonly name: string; readonly optional: boolean }[];
52
+ readonly status: number | "default" | `${1 | 2 | 3 | 4 | 5}XX`;
53
+ readonly schema: ZodType | undefined;
54
+ }
55
+
56
+ /**
57
+ * The arm that answers for a status, in the document's own precedence order.
58
+ *
59
+ * Exact code, then the `NXX` range, then `default`. Sorting the rule out once here is what stops
60
+ * every application re-deriving it, and getting it silently wrong when a `404` and a `4XX` both apply.
61
+ */
62
+ export function armFor(arms: readonly ResponseArm[], status: number): ResponseArm | undefined {
63
+ return (
64
+ arms.find((arm) => arm.status === status) ??
65
+ arms.find((arm) => arm.status === `${Math.floor(status / 100) as 1 | 2 | 3 | 4 | 5}XX`) ??
66
+ arms.find((arm) => arm.status === "default")
67
+ );
68
+ }
69
+
70
+ /** An OpenAPI security requirement: scheme name to the scopes it demands. */
71
+ export type SecurityRequirement = Readonly<Record<string, readonly string[]>>;
72
+
73
+ export type Awaitable<T> = T | Promise<T>;
74
+
75
+ /** What the document declares a tool may answer with, handed to {@link toolResult}. */
76
+ export interface ToolResponses {
77
+ /** The operation's declared responses, in OpenAPI's precedence order. */
78
+ readonly arms: readonly ResponseArm[];
79
+ /**
80
+ * The schema this tool PUBLISHED, or `undefined` where it published none.
81
+ *
82
+ * Carried separately from the arms because MCP obliges a tool declaring an `outputSchema` to
83
+ * return conforming `structuredContent`, and which arm's schema was published is a decision the
84
+ * emitter made rather than something readable off the arms.
85
+ */
86
+ readonly outputSchema: ZodType | undefined;
87
+ /**
88
+ * How a body is answered under a media type that is not JSON, keyed by the arm's status key
89
+ * (`"200"`, `"4XX"`, `"default"`), and present only where one applies.
90
+ *
91
+ * **Carried because an arm cannot say it.** The arm names the media types and the schema, and
92
+ * neither tells a string body apart from raw bytes or from a model the handler serialised: an
93
+ * `image/png` body and an `application/xml` one both arrive as a schema and a non-JSON type. The
94
+ * emitter knows, from what the document declares the body to be.
95
+ *
96
+ * - `"text"`: a string, parsed against the arm's schema and answered as a `text` block;
97
+ * - `"binary"`: raw bytes, answered as an `image` or `audio` block.
98
+ *
99
+ * A status with no entry answers a non-JSON body as the text the handler supplied, unvalidated. A
100
+ * body served as JSON needs no entry, because the served media type says so.
101
+ */
102
+ readonly bodies?: { readonly [status: string]: "text" | "binary" };
103
+ }
104
+
105
+ /**
106
+ * A response body that does not match the schema the document publishes for its status.
107
+ *
108
+ * **Thrown, so the application decides what a contract failure answers with**, the same class and
109
+ * the same fields as the sibling server emitter's. The MCP server turns a thrown error into a
110
+ * tool-execution error; an application that wants something else catches it around `registerTools`'
111
+ * handlers.
112
+ *
113
+ * `issues` are Zod's, so they carry paths and codes. They also carry the offending VALUES; an
114
+ * application that logs them should decide whether its results may contain anything it would not
115
+ * log.
116
+ */
117
+ export class ResponseContractError extends Error {
118
+ constructor(
119
+ readonly operationId: string,
120
+ readonly status: number,
121
+ readonly issues: ZodError["issues"],
122
+ ) {
123
+ super(`${operationId} answered ${status} with a body its document does not permit`);
124
+ this.name = "ResponseContractError";
125
+ }
126
+ }
127
+
128
+ /**
129
+ * A handler answered with a status its operation does not declare.
130
+ *
131
+ * **Unreachable from a typed handler.** The generated `Tools` interface types every result as the
132
+ * union of the declared statuses, so a literal outside it does not compile. This is what a CAST
133
+ * produces, or a result that crossed a boundary the type system cannot see into. Thrown rather than
134
+ * answered, because answering would publish a status the contract does not state.
135
+ */
136
+ export class UndeclaredStatusError extends Error {
137
+ constructor(
138
+ readonly operationId: string,
139
+ readonly result: unknown,
140
+ ) {
141
+ const status =
142
+ typeof result === "object" && result !== null && "status" in result
143
+ ? String(result.status)
144
+ : "no status";
145
+ super(`${operationId} answered ${status}, which its document does not declare`);
146
+ this.name = "UndeclaredStatusError";
147
+ }
148
+ }
149
+
150
+ /** A media type whose body is a JSON value. The rule the library and the server emitter apply. */
151
+ function isJsonMediaType(type: string): boolean {
152
+ return /^application\/(json|.*\+json)$/.test(type);
153
+ }
154
+
155
+ /** RFC 9110 `token`, less `*`, which names a range rather than a type. */
156
+ const MEDIA_TOKEN = "[!#$%&'+.^_`|~0-9A-Za-z-]+";
157
+ const SERVED_MEDIA_TYPE = new RegExp(`^(${MEDIA_TOKEN})/${MEDIA_TOKEN}\\s*(;.*)?$`);
158
+
159
+ /**
160
+ * Whether an offered media type, which may be a range such as `image/*`, admits a served one.
161
+ *
162
+ * The sibling server emitter's `mediaTypeWithin`, rule for rule: a range admits a concrete type inside
163
+ * it, never its own spelling or a type with no subtype; type names compare case-insensitively
164
+ * (RFC 9110 section 8.3.1), and parameters such as `charset` are allowed.
165
+ */
166
+ function admits(offered: string, served: string): boolean {
167
+ if (!offered.endsWith("/*")) return offered === served;
168
+ const type = SERVED_MEDIA_TYPE.exec(served)?.[1];
169
+ if (type === undefined) return false;
170
+ if (offered === "*/*") return true;
171
+ return type.toLowerCase() === offered.slice(0, -2).toLowerCase();
172
+ }
173
+
174
+ /**
175
+ * The media type a result is served as, or `undefined` where the arm names none.
176
+ *
177
+ * Where the arm offers several, or only a wildcard range, the handler names the one it answers with,
178
+ * and a type the arm does not offer is as undeclared as a status it does not. A range admits every
179
+ * type inside it: `image/*` is what lets a handler answer `image/png`.
180
+ */
181
+ function mediaTypeOf(
182
+ arm: ResponseArm,
183
+ contentType: unknown,
184
+ operationId: string,
185
+ result: unknown,
186
+ ): string | undefined {
187
+ const offered = arm.contentTypes ?? [];
188
+ const choosing = offered.length > 1 || offered.some((type) => type.endsWith("/*"));
189
+ if (!choosing) return offered[0];
190
+ if (typeof contentType === "string" && offered.some((type) => admits(type, contentType))) {
191
+ return contentType;
192
+ }
193
+ throw new UndeclaredStatusError(operationId, result);
194
+ }
195
+
196
+ /** A body that is not what its arm says it is, as one issue in Zod's own shape. */
197
+ function refusedBody(input: unknown, message: string): ZodError["issues"] {
198
+ return [{ code: "custom", path: [], message, input }];
199
+ }
200
+
201
+ /**
202
+ * The bytes of a binary body, which a handler may hand over in any of the forms the result type admits.
203
+ *
204
+ * A stream is buffered: a tool result is one message, and the block carries the whole of it.
205
+ * `Response` reads it, because both runtimes this output runs on, Node and workerd, provide it.
206
+ */
207
+ async function bytesOf(body: unknown): Promise<Uint8Array | undefined> {
208
+ if (body instanceof Uint8Array) return body;
209
+ if (body instanceof ArrayBuffer) return new Uint8Array(body);
210
+ if (body instanceof ReadableStream) return new Uint8Array(await new Response(body).arrayBuffer());
211
+ return undefined;
212
+ }
213
+
214
+ /** Base64, in chunks, so a large body does not exceed the argument limit of one call. */
215
+ function base64Of(bytes: Uint8Array): string {
216
+ let binary = "";
217
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
218
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
219
+ }
220
+ return btoa(binary);
221
+ }
222
+
223
+ /** One block of a tool result, as the MCP specification defines the ones this runtime answers with. */
224
+ type ContentBlock =
225
+ | { readonly type: "text"; readonly text: string }
226
+ | { readonly type: "image" | "audio"; readonly data: string; readonly mimeType: string };
227
+
228
+ /**
229
+ * The MCP result for what a handler returned: `{ status, body, headers }` for a response the
230
+ * document declares.
231
+ *
232
+ * **In the runtime rather than in `deps`, on the same test the sibling emitter applies**: every rule
233
+ * below is published -- by the document (the arms, their bodies, their headers) or by the MCP
234
+ * specification (the result shape, its content blocks, `isError`, the text mirror). An application
235
+ * forced to supply this would be re-implementing two standards, and getting either wrong differently
236
+ * from everybody else.
237
+ *
238
+ * **What is answered is the PARSED body, not the one the handler returned**, as on the HTTP surface:
239
+ * a schema that strips undeclared keys strips them here too, so a field the document does not publish
240
+ * does not reach a model.
241
+ *
242
+ * Asynchronous because a binary body may be a stream, which is read to its end before it is answered.
243
+ */
244
+ export async function toolResult(
245
+ declared: ToolResponses,
246
+ operationId: string,
247
+ result: unknown,
248
+ ): Promise<CallToolResult> {
249
+ const answer: {
250
+ readonly status?: unknown;
251
+ readonly contentType?: unknown;
252
+ readonly body?: unknown;
253
+ readonly headers?: unknown;
254
+ } = typeof result === "object" && result !== null ? result : {};
255
+ const { status } = answer;
256
+ const arm = typeof status === "number" ? armFor(declared.arms, status) : undefined;
257
+ if (arm === undefined || typeof status !== "number") {
258
+ throw new UndeclaredStatusError(operationId, result);
259
+ }
260
+ const mediaType = mediaTypeOf(arm, answer.contentType, operationId, result);
261
+
262
+ /**
263
+ * **`isError` is decided from the STATUS, not from whether the arm is an error model.**
264
+ *
265
+ * MCP's `isError` marks a tool-execution error the model can self-correct from, which is exactly
266
+ * what a declared 4xx or 5xx is: the service's own published failure, with a body the model can
267
+ * read. It is decided from the status because `armFor` may resolve to a range or `default`, whose
268
+ * numeric status only the result carries.
269
+ */
270
+ const isError = status >= 400;
271
+
272
+ const toolAnswer: {
273
+ content: ContentBlock[];
274
+ structuredContent?: unknown;
275
+ isError?: boolean;
276
+ _meta?: Record<string, unknown>;
277
+ } = { content: [] };
278
+
279
+ /** Whether the body was answered as JSON text, which `structuredContent` then mirrors. */
280
+ let servedAsJson = false;
281
+
282
+ /**
283
+ * A bodyless arm carries nothing a model can read, and inventing a confirmation sentence would
284
+ * state something the document does not, so `content` stays `[]`: present, an array, and honest.
285
+ */
286
+ if (arm.schema !== undefined && answer.body !== undefined) {
287
+ const kind = declared.bodies?.[String(arm.status)];
288
+ if (mediaType === undefined || isJsonMediaType(mediaType)) {
289
+ /**
290
+ * **Validated wherever the body is served as JSON**, which is also the only form a tool result
291
+ * can carry structurally. An arm naming no media type is a hand-written arm, and a tool
292
+ * result's text is JSON, so it is validated too.
293
+ */
294
+ const parsed = arm.schema.safeParse(answer.body);
295
+ if (!parsed.success) {
296
+ throw new ResponseContractError(operationId, status, parsed.error.issues);
297
+ }
298
+ servedAsJson = true;
299
+ // The text mirror is kept even beside `structuredContent`: MCP recommends it for clients
300
+ // that read only `content`.
301
+ toolAnswer.content = [{ type: "text", text: JSON.stringify(parsed.data) }];
302
+ /**
303
+ * **Where this arm's schema IS the published `outputSchema`.** MCP obliges a tool that
304
+ * declares an `outputSchema` to return conforming `structuredContent`, and a failure's body
305
+ * does not conform to the success schema the tool published.
306
+ */
307
+ if (declared.outputSchema !== undefined && arm.schema === declared.outputSchema) {
308
+ toolAnswer.structuredContent = parsed.data;
309
+ }
310
+ } else if (kind === "text") {
311
+ /**
312
+ * **A string body under a media type that is not JSON IS the text**, so it is checked against
313
+ * its schema exactly as the sibling server emitter checks it, and the parsed string answered.
314
+ */
315
+ const parsed = arm.schema.safeParse(answer.body);
316
+ if (!parsed.success) {
317
+ throw new ResponseContractError(operationId, status, parsed.error.issues);
318
+ }
319
+ toolAnswer.content = [{ type: "text", text: String(parsed.data) }];
320
+ } else if (kind === "binary") {
321
+ /**
322
+ * **Raw bytes are answered as the block MCP defines for their media type**, carrying the data
323
+ * as base64 and the media type served. The emitter refuses every other binary media type, so
324
+ * one reaching here was declared by hand.
325
+ */
326
+ const block = /^(image|audio)\//.exec(mediaType)?.[1];
327
+ if (block !== "image" && block !== "audio") {
328
+ throw new ResponseContractError(
329
+ operationId,
330
+ status,
331
+ refusedBody(answer.body, `no MCP content block carries a binary body as ${mediaType}`),
332
+ );
333
+ }
334
+ const bytes = await bytesOf(answer.body);
335
+ if (bytes === undefined) {
336
+ throw new ResponseContractError(
337
+ operationId,
338
+ status,
339
+ refusedBody(
340
+ answer.body,
341
+ "a binary body is a Uint8Array, an ArrayBuffer or a ReadableStream",
342
+ ),
343
+ );
344
+ }
345
+ toolAnswer.content = [{ type: block, data: base64Of(bytes), mimeType: mediaType }];
346
+ } else {
347
+ /**
348
+ * **A model under a media type that is not JSON is text the handler serialised**, which the
349
+ * schema does not describe, so it is answered as it is. `unvalidated-response-media-type`
350
+ * said so at compile time. Anything but a string is not that text.
351
+ */
352
+ if (typeof answer.body !== "string") {
353
+ throw new ResponseContractError(
354
+ operationId,
355
+ status,
356
+ refusedBody(
357
+ answer.body,
358
+ `a body answered as ${mediaType} is the text the handler supplies`,
359
+ ),
360
+ );
361
+ }
362
+ toolAnswer.content = [{ type: "text", text: answer.body }];
363
+ }
364
+ }
365
+
366
+ /**
367
+ * **A success the published `outputSchema` does not describe fails here, by name.**
368
+ *
369
+ * The SDK refuses a result that is not an error and carries no `structuredContent` once a tool
370
+ * declares an `outputSchema`. The emitter publishes one only where every 2xx and 3xx response the
371
+ * document declares shares its body, but `default` covers the statuses nobody listed, 2xx and 3xx
372
+ * among them. Such a success is answered only if its body also meets the published schema;
373
+ * otherwise it is the contract failure it is, rather than the SDK's "no structured content".
374
+ *
375
+ * **The body the HANDLER returned is checked, not the one its own arm parsed.** An object schema
376
+ * strips the keys it does not declare, so a `Problem`-parsed body has already lost every key a
377
+ * `Widget` needs: checking that would refuse a body that meets both. The text mirror is then the
378
+ * published schema's parse, because that is what `structuredContent` carries.
379
+ */
380
+ if (
381
+ !isError &&
382
+ declared.outputSchema !== undefined &&
383
+ toolAnswer.structuredContent === undefined
384
+ ) {
385
+ const conforming = declared.outputSchema.safeParse(answer.body);
386
+ if (!conforming.success) {
387
+ throw new ResponseContractError(operationId, status, conforming.error.issues);
388
+ }
389
+ toolAnswer.structuredContent = conforming.data;
390
+ if (servedAsJson) {
391
+ toolAnswer.content = [{ type: "text", text: JSON.stringify(conforming.data) }];
392
+ }
393
+ }
394
+
395
+ if (isError) toolAnswer.isError = true;
396
+
397
+ /**
398
+ * **Response headers go to `_meta`, keyed by the WIRE name, never to `structuredContent`.**
399
+ *
400
+ * Putting them in `structuredContent` breaks the declared `outputSchema`, which MCP forbids;
401
+ * dropping them is a loss the consumer cannot see. `_meta` is MCP's slot for implementation data
402
+ * outside the tool contract, and the key is namespaced so it collides with nothing. Only the
403
+ * headers the arm declares are carried, and an optional one the handler left out is omitted. A
404
+ * model cannot read `_meta`, so a value that matters to a caller belongs in the response body --
405
+ * which `headers-only-success` warns about.
406
+ */
407
+ const supplied =
408
+ typeof answer.headers === "object" && answer.headers !== null
409
+ ? (answer.headers as Readonly<Record<string, unknown>>)
410
+ : {};
411
+ const carried: Record<string, unknown> = {};
412
+ for (const header of arm.headers ?? []) {
413
+ const value = supplied[header.name];
414
+ if (value !== undefined) carried[header.name] = value;
415
+ }
416
+ if (Object.keys(carried).length > 0) {
417
+ toolAnswer._meta = { "typespec-http-mcp/response-headers": carried };
418
+ }
419
+
420
+ return toolAnswer as CallToolResult;
421
+ }
422
+
423
+ /**
424
+ * What the emitter cannot derive from the document, and the application must answer.
425
+ *
426
+ * The admission test for a member is the sibling emitter's: *the generated code cannot proceed
427
+ * without an answer*. Everything derivable lives above, in the runtime.
428
+ *
429
+ * **`C` is the caller context, and `registerTools` INFERS it from `context`.** An application whose
430
+ * `context` returns a `Caller` has handlers typed `(ctx: Caller, input)`, with nothing to declare.
431
+ */
432
+ export interface ToolDeps<C = unknown> {
433
+ /**
434
+ * Whether the caller this server was built for may SEE this tool.
435
+ *
436
+ * **Consulted at REGISTRATION, not at call time.** The MCP specification says the tool set may
437
+ * vary by the authorisation presented on the request, since credentials are per-request input
438
+ * rather than connection state -- so the idiomatic gesture is that a caller does not see a tool
439
+ * they cannot call, rather than being refused after calling it. That is a genuine divergence
440
+ * from an HTTP surface, where a 403 is the right answer and middleware is the right shape.
441
+ *
442
+ * Receives the `security` the document publishes for the operation: satisfying ANY ONE requirement
443
+ * authorises, and every scheme WITHIN a requirement must be satisfied together. An anonymous
444
+ * alternative is the empty requirement `{}`, which every caller satisfies: `@useAuth(NoAuth)` is
445
+ * `[{}]` and `NoAuth | BearerAuth` is `[{}, { BearerAuth: [] }]`. An operation declaring no auth
446
+ * at all is `[]`. Both are facts rather than absences, because an application may still choose to
447
+ * hide a public tool from an anonymous caller.
448
+ *
449
+ * A grant that selects nothing is not an error: nothing is registered and `tools/list` answers
450
+ * with a well-formed empty list. `registerTools` returns the names it did register, which is how
451
+ * an application observes that.
452
+ */
453
+ readonly permits: (requirements: readonly SecurityRequirement[]) => boolean;
454
+
455
+ /**
456
+ * The caller's context, or `null` when there is none to establish.
457
+ *
458
+ * `authentication` is what the DOCUMENT says and only that, decided at generation time:
459
+ *
460
+ * - `"none"`: no alternative asks for anything, `@useAuth(NoAuth)` or no auth at all;
461
+ * - `"optional"`: an anonymous alternative sits beside a real one, `NoAuth | BearerAuth`, so a
462
+ * caller presenting a credential is recognised and one presenting none is still admitted;
463
+ * - `"required"`: every alternative asks for something.
464
+ *
465
+ * Nothing richer is offered, because nothing richer is published -- the sibling emitter once
466
+ * invented a distinction from whether a path had parameters, which no OpenAPI keyword expresses.
467
+ */
468
+ readonly context: (
469
+ context: ServerContext,
470
+ authentication: "none" | "optional" | "required",
471
+ ) => C | null;
472
+
473
+ /** The result when {@link context} returns `null`. The application's to decide. */
474
+ readonly noContext: (context: ServerContext) => CallToolResult;
475
+ }