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.
@@ -0,0 +1,545 @@
1
+ import { jsDocComment, objectKey, } from "typespec-http-zod";
2
+ import { annotationsFor } from "./annotations.js";
3
+ import { bodyKindsOf, bodyServingOf, isJsonMediaType, isMediaRange, membersOf } from "./bodies.js";
4
+ import { CONTENTLESS_STATUS_CODES, STATUS_GROUPS } from "./statuses.js";
5
+ /**
6
+ * **Rendering lives here, with everything else that produces text.**
7
+ *
8
+ * These two were declared beside the derivations that feed them, which read them straight into a
9
+ * template. That put a compiler-reading module on the renderer's import path for the sake of a
10
+ * `JSON.stringify`, so `tools.ts` -- which produces nothing but text -- transitively depended on
11
+ * `@typespec/compiler` and `@typespec/http`. A derivation module reads the program; this one writes
12
+ * the file; neither needs the other's dependencies.
13
+ */
14
+ /** The requirements as a TypeScript literal, for the generated call site. */
15
+ function renderSecurity(requirements) {
16
+ return `[${requirements
17
+ .map((requirement) => `{ ${Object.entries(requirement)
18
+ .map(([scheme, scopes]) => `${JSON.stringify(scheme)}: [${scopes.map((s) => JSON.stringify(s)).join(", ")}]`)
19
+ .join(", ")} }`)
20
+ .join(", ")}]`;
21
+ }
22
+ /** The annotations as a TypeScript object literal, for the generated call site. */
23
+ function renderAnnotations(annotations) {
24
+ const entries = Object.entries(annotations).map(([key, value]) => `${key}: ${String(value)}`);
25
+ return `{ ${entries.join(", ")} }`;
26
+ }
27
+ /**
28
+ * The helper types the generated signatures need, the same types the sibling emitter declares.
29
+ *
30
+ * **Emitted only where a signature uses one.** A generated file has to pass the lint of whatever
31
+ * project it lands in, and `noUnusedLocals` is an error under the settings a consumer builds with:
32
+ * a service with no parameters anywhere never uses `Fields`, and one whose every result carries no
33
+ * validated body never uses `Produced`. Measured across the conformance corpus, that was 29 of 65
34
+ * emitted files failing to compile.
35
+ */
36
+ const FIELDS_HELPER = `/**
37
+ * A validator's fields, or nothing at all when it declares none.
38
+ *
39
+ * **Zod 4 infers \`Record<string, never>\` for \`z.object({})\`.** That is a fair reading on its own -
40
+ * no string key may exist - and lethal in an intersection: \`{ id: string } & Record<string, never>\`
41
+ * makes \`id\` into \`string & never\`, so the input type is uninhabitable and the call site below
42
+ * cannot compile. An empty model contributes nothing to an operation's input, and \`unknown\` is the
43
+ * identity of \`&\`, so that is what it becomes.
44
+ */
45
+ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown : T) : T;
46
+
47
+ `;
48
+ const PRODUCED_HELPER = `/**
49
+ * What a handler must SUPPLY, as opposed to what it receives.
50
+ *
51
+ * An index signature and \`readonly\` are true of an inferred type without being obligations on a
52
+ * producer, so both are dropped here - only where the shape has declared keys beside it, because an
53
+ * index signature also arrives as a property's own declared type, where stripping it would destroy
54
+ * the property. An explicit \`| undefined\` on an optional property is deliberately NOT removed:
55
+ * dropping an index signature makes MORE values assignable, and removing \`| undefined\` makes fewer.
56
+ */
57
+ type ProducedKeyOf<T> = keyof {
58
+ [K in keyof T as string extends K
59
+ ? never
60
+ : number extends K
61
+ ? never
62
+ : symbol extends K
63
+ ? never
64
+ : K]: 0;
65
+ };
66
+
67
+ type Produced<T> = T extends (...args: never[]) => unknown
68
+ ? T
69
+ : T extends readonly (infer Element)[]
70
+ ? readonly Produced<Element>[]
71
+ : T extends object
72
+ ? [ProducedKeyOf<T>] extends [never]
73
+ ? { readonly [K in keyof T]: Produced<T[K]> }
74
+ : {
75
+ readonly [K in keyof T as string extends K
76
+ ? never
77
+ : number extends K
78
+ ? never
79
+ : symbol extends K
80
+ ? never
81
+ : K]: Produced<T[K]>;
82
+ }
83
+ : T;
84
+ `;
85
+ const EMPTY_INPUT = "Record<string, never>";
86
+ /**
87
+ * Where emitted output imports its runtime from: the copy this package writes beside it.
88
+ *
89
+ * **Always this module.** `runtime-module` used to replace it, and that made an application own a
90
+ * copy of generated logic -- see the `runtime-module-removed` diagnostic.
91
+ *
92
+ * **Deliberately NOT `./runtime.gen.js`, which is what the sibling server emitter uses.** Both
93
+ * emitters run `typespec-http-zod`, and a consumer who wants both surfaces may point them at one
94
+ * output directory. `emitFile` has no duplicate-path detection -- measured, `@typespec/compiler`
95
+ * 1.15.0 `dist/src/core/emitter-utils.js:19-27` -- so two emitters writing one path is a silent
96
+ * last-writer-wins whose winner is decided by the ORDER of `emit:` in `tspconfig.yaml`. Measured
97
+ * end to end: with divergent `seal-object-schemas`, one order produced 19 unsealed objects and the
98
+ * other 11 unsealed plus 8 sealed, exit 0 and no diagnostic either time.
99
+ *
100
+ * Giving this package its own runtime filename means the only file the two can contest is
101
+ * `schemas.gen.ts`, which is byte-identical under identical options.
102
+ */
103
+ export const DEFAULT_RUNTIME_MODULE = "./mcp-runtime.gen.js";
104
+ /**
105
+ * The validators that INTERSECT into the handler's input.
106
+ *
107
+ * A body the document says must not be flattened is NAMED rather than intersected: its index
108
+ * signature would otherwise be imposed on every sibling, so an optional query parameter beside a
109
+ * dictionary body would not compile. The document states the parameters and the body separately.
110
+ */
111
+ function intersectedValidators(entry) {
112
+ const { names, route } = entry;
113
+ const groups = [names.path, names.query, names.header, names.body];
114
+ return groups.filter((name) => name !== undefined && (route.bodyProperty === undefined || name !== names.body));
115
+ }
116
+ /**
117
+ * The groups whose `.shape` the argument schema spreads: the same groups, with the query read from
118
+ * `queryFields`.
119
+ *
120
+ * **A query group can be a pipe.** Where the document explodes a record or model into query keys, the
121
+ * library's `query` validator gathers those keys back into one property before validating, and a pipe
122
+ * has no `.shape`. A tool's arguments are already one JSON object, so there is nothing to gather: the
123
+ * structural object the pipe validates is the schema a tool takes.
124
+ */
125
+ function shapedValidators(entry) {
126
+ const { names } = entry;
127
+ return intersectedValidators(entry).map((name) => name === names.query && names.queryFields !== undefined ? names.queryFields : name);
128
+ }
129
+ /** The handler's input type: the same intersection the sibling server emitter writes. */
130
+ function inputTypeOf(entry) {
131
+ const parts = intersectedValidators(entry).map((name) => `Fields<z.infer<typeof ${name}>>`);
132
+ if (entry.route.bodyProperty !== undefined && entry.names.body !== undefined) {
133
+ const optionalMark = entry.route.optionalBody ? "?" : "";
134
+ const optionalValue = entry.route.optionalBody ? " | undefined" : "";
135
+ parts.push(`{ ${objectKey(entry.route.bodyProperty)}${optionalMark}: z.infer<typeof ${entry.names.body}>${optionalValue} }`);
136
+ }
137
+ return parts.length === 0 ? EMPTY_INPUT : parts.join(" & ");
138
+ }
139
+ /**
140
+ * The tool's ARGUMENT schema: every group the document declares, merged into one object.
141
+ *
142
+ * **Merged here and nowhere else.** A tool takes one flat argument object; the document states the
143
+ * path, query, header and body separately, and the library declares them separately. Merging is
144
+ * this emitter's job.
145
+ *
146
+ * **Openness follows `seal-object-schemas`**, the same option every other emitted object follows.
147
+ * There is no merged schema in the published document, so the document has no opinion on whether
148
+ * the merge admits extra keys, and an emitter must not invent one.
149
+ *
150
+ * **A parameterless operation is SEALED**, which is what the MCP specification recommends and which
151
+ * mirrors the handler's own declared input: `Record<string, never>` for an operation with no
152
+ * parameters, and open otherwise.
153
+ */
154
+ function inputSchemaOf(entry, sealed) {
155
+ const constructor = sealed ? "z.strictObject" : "z.looseObject";
156
+ const spreads = shapedValidators(entry).map((name) => `...${name}.shape`);
157
+ if (entry.route.bodyProperty !== undefined && entry.names.body !== undefined) {
158
+ spreads.push(`${objectKey(entry.route.bodyProperty)}: ${entry.names.body}`);
159
+ }
160
+ if (spreads.length === 0)
161
+ return "z.strictObject({})";
162
+ return `${constructor}({ ${spreads.join(", ")} })`;
163
+ }
164
+ /** The codes of the status group a range key names. */
165
+ function groupCodesOf(status) {
166
+ return STATUS_GROUPS[Number(status.charAt(0))].codes;
167
+ }
168
+ /**
169
+ * The type of the `contentType` a handler names for a member: the sibling emitter's rule.
170
+ *
171
+ * **A range is typed as the types inside it**: `image/*` is `` `image/${string}` ``, so `text/html`
172
+ * under it does not compile, and TypeScript still narrows a literal member away from it. Only the
173
+ * full range is `string`.
174
+ */
175
+ function contentTypeTypeOf(mediaType) {
176
+ if (!isMediaRange(mediaType))
177
+ return JSON.stringify(mediaType);
178
+ if (mediaType === "*/*")
179
+ return "string";
180
+ return `\`${mediaType.slice(0, -1)}\${string}\``;
181
+ }
182
+ /**
183
+ * A member's status as a TypeScript type: disjoint from every other member's, by construction.
184
+ *
185
+ * **Written as a union of literals, never as Hono's type names.** This package's output imports no
186
+ * Hono, so where the sibling emitter writes `Exclude<ClientErrorStatusCode, 404 | ContentlessStatusCode>`
187
+ * this writes the codes that expression resolves to, from the same sets (`statuses.ts`, held equal to
188
+ * Hono's declaration file by test):
189
+ *
190
+ * - an exact code is itself;
191
+ * - a range is its group, less every code the operation declares exactly, and less the statuses that
192
+ * cannot carry a body where the range has one;
193
+ * - `default` is every official status from 2xx to 5xx, less the exact codes, less every code a
194
+ * declared range covers, and less the contentless statuses where it has a body.
195
+ *
196
+ * **Disjoint is the property that makes a wrong body a compile error**: were `default` allowed to
197
+ * overlap a declared status, a 404 carrying the default arm's body would type-check through it.
198
+ */
199
+ function statusTypeOf(response, route) {
200
+ const { status } = response;
201
+ if (typeof status === "number")
202
+ return String(status);
203
+ const exact = new Set(route.responses.flatMap((candidate) => typeof candidate.status === "number" ? [candidate.status] : []));
204
+ const contentless = new Set(response.schema === undefined ? [] : CONTENTLESS_STATUS_CODES);
205
+ const candidates = status === "default"
206
+ ? [2, 3, 4, 5].flatMap((digit) => STATUS_GROUPS[digit].codes)
207
+ : groupCodesOf(status);
208
+ const ranged = new Set(status === "default"
209
+ ? route.responses.flatMap((candidate) => typeof candidate.status === "string" && candidate.status !== "default"
210
+ ? groupCodesOf(candidate.status)
211
+ : [])
212
+ : []);
213
+ const codes = candidates
214
+ .filter((code) => !exact.has(code) && !ranged.has(code) && !contentless.has(code))
215
+ .toSorted((a, b) => a - b);
216
+ return codes.length === 0 ? "never" : codes.join(" | ");
217
+ }
218
+ /**
219
+ * The union a handler returns: `{ status, body, headers }` for every response the document declares.
220
+ *
221
+ * **Data rather than a transport response**, so one handler object serves this tool surface and the
222
+ * HTTP server the sibling emitter generates, and the result type is the same type in both files.
223
+ */
224
+ function resultTypeOf(entry) {
225
+ const members = membersOf(entry.route, entry.names).map((member) => {
226
+ const { response } = member;
227
+ const fields = [`readonly status: ${statusTypeOf(response, entry.route)}`];
228
+ if (member.choosesMediaType && member.mediaType !== undefined) {
229
+ fields.push(`readonly contentType: ${contentTypeTypeOf(member.mediaType)}`);
230
+ }
231
+ switch (bodyServingOf(member)) {
232
+ case "none":
233
+ fields.push("readonly body?: undefined");
234
+ break;
235
+ case "stream":
236
+ fields.push("readonly body: ReadableStream");
237
+ break;
238
+ case "binary":
239
+ fields.push("readonly body: ReadableStream | Uint8Array<ArrayBuffer> | ArrayBuffer");
240
+ break;
241
+ case "unvalidated":
242
+ fields.push("readonly body: string");
243
+ break;
244
+ default:
245
+ fields.push(`readonly body: Produced<z.infer<typeof ${member.schemaName}>>`);
246
+ }
247
+ if (response.headers.length > 0) {
248
+ /**
249
+ * Keyed by the WIRE name, as the document publishes it. `headers` itself is optional only when
250
+ * every header in it is, so a response declaring a required header cannot be returned without.
251
+ */
252
+ const every = response.headers.every((header) => header.optional);
253
+ const rendered = response.headers
254
+ .map((header) => header.optional
255
+ ? `readonly ${objectKey(header.name)}?: ${header.type} | undefined`
256
+ : `readonly ${objectKey(header.name)}: ${header.type}`)
257
+ .join("; ");
258
+ fields.push(`readonly headers${every ? "?" : ""}: { ${rendered} }`);
259
+ }
260
+ return `{ ${fields.join("; ")} }`;
261
+ });
262
+ const body = members.length <= 1
263
+ ? ` ${members[0] ?? "never"}`
264
+ : members.map((member) => `\n\t| ${member}`).join("");
265
+ return `export type ${resultTypeName(entry)} =${body};`;
266
+ }
267
+ /**
268
+ * The published operation id with its first letter raised, and nothing else changed: the same name
269
+ * the sibling emitter gives the same operation's result.
270
+ */
271
+ function resultTypeName(entry) {
272
+ const id = entry.route.operationId;
273
+ return `${id.charAt(0).toUpperCase()}${id.slice(1)}Result`;
274
+ }
275
+ /**
276
+ * The schema a tool publishes as its `outputSchema`, or `undefined` where it publishes none.
277
+ *
278
+ * **The body of the lowest exact 2xx or 3xx status**, the operation's plainest success, and only
279
+ * where every success the document declares answers with that same body, served as JSON.
280
+ *
281
+ * **The second condition is MCP's, not a preference.** A tool declaring an `outputSchema` must return
282
+ * `structuredContent` conforming to it on every result that is not an error, and the SDK client
283
+ * enforces it: `@modelcontextprotocol/client` throws "has an output schema but did not return
284
+ * structured content" for such a result. The runtime puts a body in `structuredContent` only when its
285
+ * arm's schema is the published one, so an operation answering `201` with one body and `200` with
286
+ * another, or `204` with none, would publish a schema one of its own successes cannot meet. A range
287
+ * or `default` is never the published schema itself: neither names a status a caller can expect.
288
+ */
289
+ function outputSchemaOf(entry) {
290
+ const { responses } = entry.route;
291
+ const successes = responses.flatMap((response, index) => isSuccessStatus(response.status) ? [{ response, index }] : []);
292
+ const lowest = successes
293
+ .filter((success) => typeof success.response.status === "number")
294
+ .toSorted((a, b) => Number(a.response.status) - Number(b.response.status))[0];
295
+ if (lowest === undefined)
296
+ return undefined;
297
+ const schema = entry.names.arms[lowest.index]?.schema;
298
+ if (schema === undefined)
299
+ return undefined;
300
+ const servesSchemaAsJson = ({ response, index }) => entry.names.arms[index]?.schema === schema &&
301
+ !response.streamed &&
302
+ !response.binary &&
303
+ response.contentTypes.every(isJsonMediaType);
304
+ return successes.every(servesSchemaAsJson) ? schema : undefined;
305
+ }
306
+ /** A 2xx or 3xx status key, exact or ranged. */
307
+ function isSuccessStatus(status) {
308
+ if (typeof status === "number")
309
+ return status >= 200 && status < 400;
310
+ return status === "2XX" || status === "3XX";
311
+ }
312
+ function inputSchemaName(entry) {
313
+ return `${entry.route.operationId}Input`;
314
+ }
315
+ /** The banner every generated file in this estate carries. */
316
+ function generatedBanner(hint) {
317
+ const second = hint === undefined
318
+ ? "// Recompile the spec that produced it; edits here are overwritten on the next run."
319
+ : `// Regenerate with: ${hint}`;
320
+ return `// GENERATED by typespec-http-mcp from the TypeSpec service definition. DO NOT EDIT.\n${second}\n`;
321
+ }
322
+ export function renderTools(entries, declaredOperationIds, options) {
323
+ const imported = new Set();
324
+ for (const entry of entries) {
325
+ for (const name of [...intersectedValidators(entry), ...shapedValidators(entry)]) {
326
+ imported.add(name);
327
+ }
328
+ if (entry.names.body !== undefined)
329
+ imported.add(entry.names.body);
330
+ for (const member of membersOf(entry.route, entry.names)) {
331
+ const serving = bodyServingOf(member);
332
+ if ((serving === "json" || serving === "text") && member.schemaName !== undefined) {
333
+ imported.add(member.schemaName);
334
+ }
335
+ }
336
+ const outputSchema = outputSchemaOf(entry);
337
+ if (outputSchema !== undefined)
338
+ imported.add(outputSchema);
339
+ imported.add(entry.names.responses);
340
+ }
341
+ /**
342
+ * Only the names a signature uses. `Awaitable` appears in the `Tools` properties and `toolResult`
343
+ * in each registration, so a service whose every operation is refused declares them and uses none
344
+ * -- `noUnusedLocals` is an error in the settings a consumer builds with.
345
+ *
346
+ * **Always the runtime this package writes beside the file.** It used to be whatever
347
+ * `runtime-module` named, which made an application own a copy of the rules below.
348
+ */
349
+ const runtimeTypes = ["McpServer", "ServerContext", "ToolDeps"];
350
+ if (entries.length > 0)
351
+ runtimeTypes.push("Awaitable");
352
+ const from = JSON.stringify(DEFAULT_RUNTIME_MODULE);
353
+ const runtimeImport = [
354
+ ...(entries.length > 0 ? [`import { toolResult } from ${from};`] : []),
355
+ `import type { ${runtimeTypes.toSorted().join(", ")} } from ${from};`,
356
+ ].join("\n");
357
+ const schemaImport = imported.size === 0
358
+ ? ""
359
+ : `import { ${[...imported].toSorted().join(", ")} } from "./schemas.gen.js";\n`;
360
+ /**
361
+ * Which helpers any signature actually needs. Read from the IR rather than from the rendered
362
+ * text: deciding anything by pattern-matching emitted output is the defect class this estate
363
+ * exists to remove.
364
+ */
365
+ const usesFields = entries.some((entry) => intersectedValidators(entry).length > 0);
366
+ const usesProduced = entries.some((entry) => membersOf(entry.route, entry.names).some((member) => {
367
+ const serving = bodyServingOf(member);
368
+ return serving === "json" || serving === "text";
369
+ }));
370
+ const helpers = `${usesFields ? FIELDS_HELPER : ""}${usesProduced ? PRODUCED_HELPER : ""}`;
371
+ /** Every tool declares an input schema, so `z` is used exactly when there is a tool. */
372
+ const usesZod = entries.length > 0;
373
+ /**
374
+ * Every parameter is unread when nothing is registered, and `noUnusedParameters` is an error in
375
+ * the settings a consumer builds with. The underscore says the omission is deliberate. `Tools`'
376
+ * caller context is unread for the same reason, and `noUnusedLocals` refuses an unused type
377
+ * parameter unless its name starts with one.
378
+ */
379
+ const u = entries.length === 0 ? "_" : "";
380
+ const inputConsts = entries
381
+ .map((entry) => `const ${inputSchemaName(entry)} = ${inputSchemaOf(entry, options.sealed)};`)
382
+ .join("\n");
383
+ const resultTypes = entries.map(resultTypeOf).join("\n\n");
384
+ /**
385
+ * **A PROPERTY signature, not a method signature, and the difference is a check.** TypeScript
386
+ * compares a method signature's parameters bivariantly, so a handler declaring a richer caller
387
+ * context than `deps.context` produces would compile against the method form.
388
+ */
389
+ const methods = entries
390
+ .map((entry) => {
391
+ const doc = jsDocComment(entry.description, "\t");
392
+ return `${doc}\treadonly ${objectKey(entry.route.operationId)}: (ctx: C, input: ${inputTypeOf(entry)}) => Awaitable<${resultTypeName(entry)}>;`;
393
+ })
394
+ .join("\n");
395
+ /** Every scope any selected operation demands, de-duplicated and ordered. */
396
+ const scopes = [...new Set(entries.flatMap((entry) => [...entry.route.scopes]))].toSorted();
397
+ const scopesLiteral = `[${scopes.map((scope) => JSON.stringify(scope)).join(", ")}]`;
398
+ const declaredUnion = declaredOperationIds.length === 0
399
+ ? "never"
400
+ : declaredOperationIds.map((id) => JSON.stringify(id)).join(" | ");
401
+ /**
402
+ * **Registered in a deterministic order, sorted by tool name.**
403
+ *
404
+ * The MCP specification says a server SHOULD return tools in a deterministic order: it lets a
405
+ * client cache the list, and it improves prompt-cache hit rates when the tools are put in a
406
+ * model's context. Route order is not stable across spec edits; a name sort is.
407
+ */
408
+ const ordered = [...entries].toSorted((a, b) => a.toolName.localeCompare(b.toolName));
409
+ const registrations = ordered
410
+ .map((entry) => {
411
+ const title = entry.title === undefined ? "" : `\n\t\t\t\ttitle: ${JSON.stringify(entry.title)},`;
412
+ const description = entry.description === undefined
413
+ ? ""
414
+ : `\n\t\t\t\tdescription: ${JSON.stringify(entry.description)},`;
415
+ const published = outputSchemaOf(entry);
416
+ const outputSchema = published === undefined ? "" : `\n\t\t\t\toutputSchema: ${published},`;
417
+ /**
418
+ * The body kinds the runtime cannot read off an arm, only where one applies: a JSON body is
419
+ * known from its media type, and most operations answer nothing else.
420
+ */
421
+ const kinds = bodyKindsOf(entry.route);
422
+ const bodies = kinds.length === 0
423
+ ? ""
424
+ : `, bodies: { ${kinds.map(([status, kind]) => `${JSON.stringify(status)}: ${JSON.stringify(kind)}`).join(", ")} }`;
425
+ const declared = `{ arms: ${entry.names.responses}, outputSchema: ${published ?? "undefined"}${bodies} }`;
426
+ /**
427
+ * **A parameterless tool must not DECLARE an argument it never reads.**
428
+ *
429
+ * `noUnusedParameters` is an error in the settings a consumer builds with, so `async (args,
430
+ * ...)` on an operation with no parameters is `TS6133` in a file they cannot edit. The
431
+ * callback still receives the argument; it is named `_args` so the compiler knows the
432
+ * omission is deliberate.
433
+ */
434
+ const parameterless = intersectedValidators(entry).length === 0 && entry.route.bodyProperty === undefined;
435
+ const argument = parameterless ? "{}" : "{ ...args }";
436
+ const argumentName = parameterless ? "_args" : "args";
437
+ return ` {
438
+ const tool = server.registerTool(
439
+ ${JSON.stringify(entry.toolName)},
440
+ {${title}${description}
441
+ inputSchema: ${inputSchemaName(entry)},${outputSchema}
442
+ annotations: ${renderAnnotations(annotationsFor(entry.route.verb))},
443
+ },
444
+ async (${argumentName}, context) => {
445
+ const ctx = deps.context(context, ${JSON.stringify(entry.route.authentication)});
446
+ if (ctx === null) return deps.noContext(context);
447
+ return toolResult(
448
+ ${declared},
449
+ ${JSON.stringify(entry.route.operationId)},
450
+ await handlersFor(context).${entry.route.operationId}(ctx, ${argument}),
451
+ );
452
+ },
453
+ );
454
+ if (deps.permits(${renderSecurity(entry.requirements)})) {
455
+ registered.push(${JSON.stringify(entry.toolName)});
456
+ } else {
457
+ tool.remove();
458
+ }
459
+ }`;
460
+ })
461
+ .join("\n");
462
+ const text = `${generatedBanner(options.hint)}
463
+ ${usesZod ? 'import { z } from "zod";\n' : ""}${runtimeImport}
464
+ ${schemaImport}
465
+ ${helpers}
466
+ ${inputConsts}
467
+ ${resultTypes === "" ? "" : `\n${resultTypes}\n`}
468
+ /**
469
+ * One handler per TOOL, each concretely typed from the schemas it validates against, returning
470
+ * \`{ status, body, headers }\` for any response the document declares.
471
+ *
472
+ * **These are the same signatures a generated HTTP server emits for the same operations**, derived
473
+ * from the same artefacts by the same rules, so ONE handler implementation serves both surfaces.
474
+ * \`C\` is the caller context \`deps.context\` establishes.
475
+ */
476
+ export interface Tools<${u}C = unknown> {
477
+ ${methods}
478
+ }
479
+
480
+ /**
481
+ * Every operation the SERVICE declares, whether or not it became a tool.
482
+ *
483
+ * **Not \`keyof Tools\`, and the difference is the whole reason this exists.** A tool set is a
484
+ * selected SUBSET, and an application passes the same handler object to both surfaces, so a handler
485
+ * for a non-tool operation is expected rather than surplus. Excluding against this union instead
486
+ * keeps the removal guard exact: a key naming no declared operation is still \`never\`.
487
+ */
488
+ export type DeclaredOperationId = ${declaredUnion};
489
+
490
+ /**
491
+ * Refuses a handler set carrying an operation the spec no longer declares.
492
+ *
493
+ * Removal is the change the type system otherwise misses: adding an operation is a missing property
494
+ * and changing one is a wrong signature, but removing one leaves a handler that compiles forever
495
+ * against a tool nobody registers. The excess-property check catches it only on an object literal
496
+ * assigned to an annotated target, so mapping every surplus key to \`never\` makes the refusal
497
+ * structural instead.
498
+ *
499
+ * **Write the handler factory UNANNOTATED.** Annotating widens the value, \`T\` infers as the
500
+ * annotation, \`Exclude<keyof T, DeclaredOperationId>\` becomes \`never\`, and this guard evaporates.
501
+ */
502
+ export type ToolsExhaustive<T> = T & Record<Exclude<keyof T, DeclaredOperationId>, never>;
503
+
504
+ /**
505
+ * Every scope any selected operation demands, for the Protected Resource Metadata document.
506
+ *
507
+ * **The one authentication fact this emitter can state that the SDK cannot.** RFC 9728's
508
+ * \`scopes_supported\` is what a client reads to know which scopes to ask an Authorization Server
509
+ * for, and it is exactly the union of what the spec's \`@useAuth\` declares. Everything else about
510
+ * authentication -- verifying a token, building the metadata document, answering a 401 with a
511
+ * \`WWW-Authenticate\` challenge -- is the SDK's, and is not reimplemented here. Pass this to
512
+ * \`buildOAuthProtectedResourceMetadata\`'s \`scopesSupported\`.
513
+ *
514
+ * Empty where the service declares no scopes, which is a fact rather than an absence: a bearer or
515
+ * api-key scheme demands none.
516
+ */
517
+ export const SCOPES_SUPPORTED = ${scopesLiteral} as const;
518
+
519
+ /** Every tool this file registers, in the order it registers them. */
520
+ export const TOOL_NAMES = [${ordered.map((entry) => JSON.stringify(entry.toolName)).join(", ")}] as const;
521
+
522
+ /**
523
+ * Register every tool this service declares, onto a server the APPLICATION owns.
524
+ *
525
+ * **The server is passed in, never constructed here.** The application owns the transport, the
526
+ * session lifetime and the authentication that precedes both; this file owns the tool definitions
527
+ * and the dispatch into handlers, and nothing else.
528
+ *
529
+ * **\`deps.permits\` is consulted at REGISTRATION, not at call time**, because the MCP specification
530
+ * says a tool set may vary by the authorisation presented on the request. A caller does not see a
531
+ * tool they cannot call. Returns the names actually registered, which is the only way an application
532
+ * can observe a grant that selected nothing.
533
+ */
534
+ export function registerTools<C, T extends Tools<C>>(
535
+ ${u}server: McpServer,
536
+ ${u}handlersFor: (context: ServerContext) => ToolsExhaustive<T>,
537
+ ${u}deps: ToolDeps<C>,
538
+ ): readonly string[] {
539
+ const registered: string[] = [];
540
+ ${registrations}
541
+ return registered;
542
+ }
543
+ `;
544
+ return { text, emitted: entries.map((entry) => entry.route.operationId) };
545
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * What TypeSpec's compiler loads for this library.
3
+ *
4
+ * **There is deliberately no `$decorators` export**, here or in `typespec-http-zod`. Four once
5
+ * existed there and every one let a spec state something `@typespec/openapi3` could not publish, so
6
+ * the emitted validator enforced a rule no caller reading the contract could see. A decorator is not
7
+ * a convenience; it is a second contract.
8
+ *
9
+ * The temptation here is sharper than it was there, because MCP has concepts HTTP does not: a tool
10
+ * hint, a title, an icon. Every one of them is either derivable from what the document already says
11
+ * -- `readOnlyHint` from the method's safety, `title` from `@summary` -- or is a fact no reader of
12
+ * the document could see. Selection is by `@tag`, which openapi3 publishes, for exactly this reason.
13
+ */
14
+ export { $lib } from "./lib.js";
@@ -0,0 +1,14 @@
1
+ /**
2
+ * What TypeSpec's compiler loads for this library.
3
+ *
4
+ * **There is deliberately no `$decorators` export**, here or in `typespec-http-zod`. Four once
5
+ * existed there and every one let a spec state something `@typespec/openapi3` could not publish, so
6
+ * the emitted validator enforced a rule no caller reading the contract could see. A decorator is not
7
+ * a convenience; it is a second contract.
8
+ *
9
+ * The temptation here is sharper than it was there, because MCP has concepts HTTP does not: a tool
10
+ * hint, a title, an icon. Every one of them is either derivable from what the document already says
11
+ * -- `readOnlyHint` from the method's safety, `title` from `@summary` -- or is a fact no reader of
12
+ * the document could see. Selection is by `@tag`, which openapi3 publishes, for exactly this reason.
13
+ */
14
+ export { $lib } from "./lib.js";
package/lib/main.tsp ADDED
@@ -0,0 +1,3 @@
1
+ // The emitter library itself: its diagnostics and its options schema. There are no decorators; see
2
+ // `src/tsp-index.ts` for why, and `typespec-http-zod` for the rest of the options this accepts.
3
+ import "../dist/src/tsp-index.js";
package/package.json ADDED
@@ -0,0 +1,95 @@
1
+ {
2
+ "name": "typespec-http-mcp",
3
+ "version": "0.1.0",
4
+ "description": "TypeSpec emitter: generate an MCP tool surface, and the Zod validators it publishes, from an HTTP service definition, dispatching into the same handlers a generated HTTP server calls.",
5
+ "keywords": [
6
+ "cloudflare-workers",
7
+ "codegen",
8
+ "mcp",
9
+ "model-context-protocol",
10
+ "openapi",
11
+ "typespec",
12
+ "typespec-emitter",
13
+ "zod"
14
+ ],
15
+ "homepage": "https://github.com/bison-digital/typespec-http-mcp#readme",
16
+ "bugs": {
17
+ "url": "https://github.com/bison-digital/typespec-http-mcp/issues"
18
+ },
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/bison-digital/typespec-http-mcp.git"
23
+ },
24
+ "files": [
25
+ "!dist/test/**",
26
+ "dist/**",
27
+ "lib/*.tsp",
28
+ "src/runtime.ts"
29
+ ],
30
+ "type": "module",
31
+ "main": "dist/src/index.js",
32
+ "exports": {
33
+ ".": {
34
+ "typespec": "./lib/main.tsp",
35
+ "types": "./dist/src/index.d.ts",
36
+ "default": "./dist/src/index.js"
37
+ },
38
+ "./runtime": {
39
+ "types": "./dist/src/runtime.d.ts",
40
+ "default": "./dist/src/runtime.js"
41
+ }
42
+ },
43
+ "publishConfig": {
44
+ "access": "public",
45
+ "provenance": true
46
+ },
47
+ "dependencies": {
48
+ "typespec-http-zod": "^0.26.0"
49
+ },
50
+ "devDependencies": {
51
+ "@hono/zod-validator": "^0.9.1",
52
+ "@modelcontextprotocol/client": "2.0.0",
53
+ "@modelcontextprotocol/server": "2.0.0",
54
+ "@types/node": "^26.5.1",
55
+ "@typespec/compiler": "1.16.0",
56
+ "@typespec/events": "0.86.0",
57
+ "@typespec/http": "1.16.0",
58
+ "@typespec/http-specs": "0.1.0-alpha.43",
59
+ "@typespec/openapi": "1.16.0",
60
+ "@typespec/openapi3": "1.16.0",
61
+ "@typespec/rest": "0.86.0",
62
+ "@typespec/sse": "0.86.0",
63
+ "@typespec/streams": "0.86.0",
64
+ "@typespec/versioning": "0.86.0",
65
+ "@typespec/xml": "0.86.0",
66
+ "hono": "^4.13.7",
67
+ "oxfmt": "^0.67.0",
68
+ "oxlint": "^1.82.0",
69
+ "typescript": "~7.0.2",
70
+ "typespec-hono": "0.23.0",
71
+ "typespec-http-mcp": "link:.",
72
+ "vitest": "^5.0.0",
73
+ "wrangler": "^4.131.1",
74
+ "zod": "^4.6.2"
75
+ },
76
+ "peerDependencies": {
77
+ "@modelcontextprotocol/server": "^2.0.0",
78
+ "@typespec/compiler": "^1.15.0",
79
+ "@typespec/http": "^1.15.0",
80
+ "zod": "^4.5.0"
81
+ },
82
+ "engines": {
83
+ "node": ">=22.0.0"
84
+ },
85
+ "tspMain": "lib/main.tsp",
86
+ "scripts": {
87
+ "build": "tsc -p tsconfig.build.json",
88
+ "test": "tsc -p tsconfig.build.json && vitest run",
89
+ "test:unit": "tsc -p tsconfig.build.json && vitest run --project unit",
90
+ "typecheck": "tsc -p tsconfig.json",
91
+ "lint": "oxlint src/ test/",
92
+ "format": "oxfmt",
93
+ "format:check": "oxfmt --check"
94
+ }
95
+ }