typespec-http-mcp 0.1.0 → 0.2.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.
@@ -1,5 +1,5 @@
1
1
  import { type Program } from "@typespec/compiler";
2
- import type { HttpOperation, HttpService } from "@typespec/http";
2
+ import type { HttpOperation } from "@typespec/http";
3
3
  import type { EmittedRoute } from "typespec-http-zod";
4
4
  /**
5
5
  * Which operations become tools.
@@ -36,14 +36,9 @@ export interface SelectionOptions {
36
36
  readonly toolTags: readonly string[] | undefined;
37
37
  readonly excludeTags: readonly string[];
38
38
  }
39
- /** The `HttpOperation` behind each emitted route, keyed by verb and path. */
40
- export declare function operationsById(service: HttpService): ReadonlyMap<string, HttpOperation>;
41
39
  /**
42
- * Look an operation up by verb and path, never by name.
43
- *
44
- * **Matching on operation NAME silently fails for every operation declared inside an `interface`**,
45
- * which is most of them in a real spec -- a defect this estate has already paid for once. The verb
46
- * and the path are what both the route and the document agree on.
40
+ * **Each route's operation is read by its operation id**, from the library's `operations`. It was
41
+ * looked up by verb and path, which `@sharedRoute` does not make unique, so every member of a shared
42
+ * route was selected by one member's tags.
47
43
  */
48
- export declare function httpOperationFor(service: HttpService, route: EmittedRoute): HttpOperation | undefined;
49
- export declare function selectTools(program: Program, service: HttpService, routes: readonly EmittedRoute[], options: SelectionOptions): Selection;
44
+ export declare function selectTools(program: Program, routes: readonly Pick<EmittedRoute, "operationId">[], operations: ReadonlyMap<string, HttpOperation>, options: SelectionOptions): Selection;
@@ -1,40 +1,14 @@
1
1
  import { getAllTags } from "@typespec/compiler";
2
2
  /**
3
- * The lookup key both sides agree on.
4
- *
5
- * **Lower-cased, and that is not cosmetic.** `EmittedRoute.verb` is UPPERCASE (`"GET"`) while
6
- * `HttpOperation.verb` is lowercase (`"get"`) -- measured, on this package's own fixture. A
7
- * case-sensitive key misses every operation, and the failure is silent in the worst possible way:
8
- * the emitter selects nothing, reports `no-tools-selected`, and the message blames the tag
9
- * configuration. It was caught here only because a fixture asserted that an unnarrowed selection
10
- * selects SOMETHING.
3
+ * **Each route's operation is read by its operation id**, from the library's `operations`. It was
4
+ * looked up by verb and path, which `@sharedRoute` does not make unique, so every member of a shared
5
+ * route was selected by one member's tags.
11
6
  */
12
- function keyOf(verb, path) {
13
- return `${verb.toLowerCase()} ${path}`;
14
- }
15
- /** The `HttpOperation` behind each emitted route, keyed by verb and path. */
16
- export function operationsById(service) {
17
- const byVerbAndPath = new Map();
18
- for (const operation of service.operations) {
19
- byVerbAndPath.set(keyOf(operation.verb, operation.path), operation);
20
- }
21
- return byVerbAndPath;
22
- }
23
- /**
24
- * Look an operation up by verb and path, never by name.
25
- *
26
- * **Matching on operation NAME silently fails for every operation declared inside an `interface`**,
27
- * which is most of them in a real spec -- a defect this estate has already paid for once. The verb
28
- * and the path are what both the route and the document agree on.
29
- */
30
- export function httpOperationFor(service, route) {
31
- return operationsById(service).get(keyOf(route.verb, route.path));
32
- }
33
- export function selectTools(program, service, routes, options) {
7
+ export function selectTools(program, routes, operations, options) {
34
8
  const available = new Set();
35
9
  const selected = [];
36
10
  for (const route of routes) {
37
- const operation = httpOperationFor(service, route);
11
+ const operation = operations.get(route.operationId);
38
12
  if (operation === undefined)
39
13
  continue;
40
14
  const tags = getAllTags(program, operation.operation) ?? [];
package/dist/src/tools.js CHANGED
@@ -1,7 +1,6 @@
1
- import { jsDocComment, objectKey, } from "typespec-http-zod";
1
+ import { jsDocComment, mediaTypeTypeOf, objectKey, statusCodesOf, } from "typespec-http-zod";
2
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";
3
+ import { bodyKindsOf, isJsonMediaType, membersOf } from "./bodies.js";
5
4
  /**
6
5
  * **Rendering lives here, with everything else that produces text.**
7
6
  *
@@ -39,10 +38,11 @@ const FIELDS_HELPER = `/**
39
38
  * **Zod 4 infers \`Record<string, never>\` for \`z.object({})\`.** That is a fair reading on its own -
40
39
  * no string key may exist - and lethal in an intersection: \`{ id: string } & Record<string, never>\`
41
40
  * 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.
41
+ * cannot compile. An empty model contributes nothing to an operation's input, so it becomes \`{}\`,
42
+ * the identity of \`&\` over the object types an input is made of - not \`unknown\`, which an input
43
+ * made of nothing else would then BE, and which the contract types' \`{}\` does not accept.
44
44
  */
45
- type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? unknown : T) : T;
45
+ type Fields<T> = string extends keyof T ? ([T[string]] extends [never] ? {} : T) : T;
46
46
 
47
47
  `;
48
48
  const PRODUCED_HELPER = `/**
@@ -51,8 +51,11 @@ const PRODUCED_HELPER = `/**
51
51
  * An index signature and \`readonly\` are true of an inferred type without being obligations on a
52
52
  * producer, so both are dropped here - only where the shape has declared keys beside it, because an
53
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.
54
+ * the property.
55
+ *
56
+ * It is applied to the schema's INPUT, \`z.input\`, because that is what a result is checked against:
57
+ * a property with a default may be left out, and an optional property is \`?: T\`, because
58
+ * \`.exactOptional()\` refuses an explicit \`undefined\`. Both are what the contract types state.
56
59
  */
57
60
  type ProducedKeyOf<T> = keyof {
58
61
  [K in keyof T as string extends K
@@ -110,7 +113,14 @@ export const DEFAULT_RUNTIME_MODULE = "./mcp-runtime.gen.js";
110
113
  */
111
114
  function intersectedValidators(entry) {
112
115
  const { names, route } = entry;
113
- const groups = [names.path, names.query, names.header, names.body];
116
+ /**
117
+ * **A cookie is merged like a header, and for the same reason.** A tool call carries neither, so
118
+ * neither is transport the tool can set - both are inputs the operation needs, and a tool's
119
+ * arguments are one object merged from every location the document declares. The alternative was
120
+ * to refuse the operation, which would remove a working tool over a distinction the MCP surface
121
+ * does not have.
122
+ */
123
+ const groups = [names.path, names.query, names.header, names.cookie, names.body];
114
124
  return groups.filter((name) => name !== undefined && (route.bodyProperty === undefined || name !== names.body));
115
125
  }
116
126
  /**
@@ -130,12 +140,30 @@ function shapedValidators(entry) {
130
140
  function inputTypeOf(entry) {
131
141
  const parts = intersectedValidators(entry).map((name) => `Fields<z.infer<typeof ${name}>>`);
132
142
  if (entry.route.bodyProperty !== undefined && entry.names.body !== undefined) {
143
+ // `?: T`, as the sibling server types it: an absent body is an absent key.
133
144
  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} }`);
145
+ parts.push(`{ ${objectKey(entry.route.bodyProperty)}${optionalMark}: z.infer<typeof ${entry.names.body}> }`);
136
146
  }
147
+ const selected = selectedAcceptOf(entry);
148
+ if (selected !== undefined)
149
+ parts.push(`{ ${selected} }`);
137
150
  return parts.length === 0 ? EMPTY_INPUT : parts.join(" & ");
138
151
  }
152
+ /**
153
+ * **The `accept` literal a tool supplies itself**, on a route several operations share, as
154
+ * `key: "literal"`.
155
+ *
156
+ * The server negotiates which operation answers, so its handler receives the one literal that reached
157
+ * it, and the header validator leaves `accept` out. A tool IS one of those operations, so the literal
158
+ * is known exactly here too: the handler receives it, and the caller is never asked for a value with
159
+ * one answer. It was missing, so a handler typed for the server could not be registered as a tool.
160
+ */
161
+ function selectedAcceptOf(entry) {
162
+ const selected = entry.names.selectedAccept;
163
+ return selected === undefined
164
+ ? undefined
165
+ : `${objectKey(selected.name)}: ${JSON.stringify(selected.value)}`;
166
+ }
139
167
  /**
140
168
  * The tool's ARGUMENT schema: every group the document declares, merged into one object.
141
169
  *
@@ -155,64 +183,27 @@ function inputSchemaOf(entry, sealed) {
155
183
  const constructor = sealed ? "z.strictObject" : "z.looseObject";
156
184
  const spreads = shapedValidators(entry).map((name) => `...${name}.shape`);
157
185
  if (entry.route.bodyProperty !== undefined && entry.names.body !== undefined) {
158
- spreads.push(`${objectKey(entry.route.bodyProperty)}: ${entry.names.body}`);
186
+ /**
187
+ * **Optional where the document lets the body be absent.** It was required here while the
188
+ * handler's input said \`body?:\`, so a tool call omitting a body the document makes optional
189
+ * was refused before it reached the handler.
190
+ */
191
+ const optional = entry.route.optionalBody ? ".exactOptional()" : "";
192
+ spreads.push(`${objectKey(entry.route.bodyProperty)}: ${entry.names.body}${optional}`);
159
193
  }
160
194
  if (spreads.length === 0)
161
195
  return "z.strictObject({})";
162
196
  return `${constructor}({ ${spreads.join(", ")} })`;
163
197
  }
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
198
  /**
169
- * The type of the `contentType` a handler names for a member: the sibling emitter's rule.
199
+ * The status a result member is typed with: the codes its response answers with, written out.
170
200
  *
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.
201
+ * **Written out, never as Hono's group types**, because this file imports nothing from Hono. The
202
+ * codes are the library's `statusCodesOf`, the same rule the server emitter and the contract types
203
+ * read, so a tool's result and a handler's result agree member by member.
198
204
  */
199
205
  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);
206
+ const codes = statusCodesOf(response, route.responses, (candidate) => candidate.schema !== undefined);
216
207
  return codes.length === 0 ? "never" : codes.join(" | ");
217
208
  }
218
209
  /**
@@ -226,23 +217,28 @@ function resultTypeOf(entry) {
226
217
  const { response } = member;
227
218
  const fields = [`readonly status: ${statusTypeOf(response, entry.route)}`];
228
219
  if (member.choosesMediaType && member.mediaType !== undefined) {
229
- fields.push(`readonly contentType: ${contentTypeTypeOf(member.mediaType)}`);
220
+ fields.push(`readonly contentType: ${mediaTypeTypeOf(member.mediaType)}`);
230
221
  }
231
- switch (bodyServingOf(member)) {
222
+ switch (member.serving) {
232
223
  case "none":
233
224
  fields.push("readonly body?: undefined");
234
225
  break;
235
226
  case "stream":
236
- fields.push("readonly body: ReadableStream");
227
+ fields.push("readonly body: ByteStream");
237
228
  break;
238
229
  case "binary":
239
- fields.push("readonly body: ReadableStream | Uint8Array<ArrayBuffer> | ArrayBuffer");
230
+ /**
231
+ * **`ByteStream`, not `ReadableStream`, so a backend's contract value fits**, as the sibling
232
+ * server types it. The contract types state a binary body structurally, because they cannot
233
+ * name the platform stream; a platform stream still satisfies it.
234
+ */
235
+ fields.push("readonly body: ByteStream | Uint8Array<ArrayBuffer> | ArrayBuffer");
240
236
  break;
241
237
  case "unvalidated":
242
238
  fields.push("readonly body: string");
243
239
  break;
244
240
  default:
245
- fields.push(`readonly body: Produced<z.infer<typeof ${member.schemaName}>>`);
241
+ fields.push(`readonly body: Produced<z.input<typeof ${member.schemaName}>>`);
246
242
  }
247
243
  if (response.headers.length > 0) {
248
244
  /**
@@ -328,7 +324,7 @@ export function renderTools(entries, declaredOperationIds, options) {
328
324
  if (entry.names.body !== undefined)
329
325
  imported.add(entry.names.body);
330
326
  for (const member of membersOf(entry.route, entry.names)) {
331
- const serving = bodyServingOf(member);
327
+ const serving = member.serving;
332
328
  if ((serving === "json" || serving === "text") && member.schemaName !== undefined) {
333
329
  imported.add(member.schemaName);
334
330
  }
@@ -349,6 +345,12 @@ export function renderTools(entries, declaredOperationIds, options) {
349
345
  const runtimeTypes = ["McpServer", "ServerContext", "ToolDeps"];
350
346
  if (entries.length > 0)
351
347
  runtimeTypes.push("Awaitable");
348
+ const usesByteStream = entries.some((entry) => membersOf(entry.route, entry.names).some((member) => {
349
+ const serving = member.serving;
350
+ return serving === "binary" || serving === "stream";
351
+ }));
352
+ if (usesByteStream)
353
+ runtimeTypes.push("ByteStream");
352
354
  const from = JSON.stringify(DEFAULT_RUNTIME_MODULE);
353
355
  const runtimeImport = [
354
356
  ...(entries.length > 0 ? [`import { toolResult } from ${from};`] : []),
@@ -364,7 +366,7 @@ export function renderTools(entries, declaredOperationIds, options) {
364
366
  */
365
367
  const usesFields = entries.some((entry) => intersectedValidators(entry).length > 0);
366
368
  const usesProduced = entries.some((entry) => membersOf(entry.route, entry.names).some((member) => {
367
- const serving = bodyServingOf(member);
369
+ const serving = member.serving;
368
370
  return serving === "json" || serving === "text";
369
371
  }));
370
372
  const helpers = `${usesFields ? FIELDS_HELPER : ""}${usesProduced ? PRODUCED_HELPER : ""}`;
@@ -418,10 +420,16 @@ export function renderTools(entries, declaredOperationIds, options) {
418
420
  * The body kinds the runtime cannot read off an arm, only where one applies: a JSON body is
419
421
  * known from its media type, and most operations answer nothing else.
420
422
  */
421
- const kinds = bodyKindsOf(entry.route);
422
- const bodies = kinds.length === 0
423
+ const kinds = new Map();
424
+ for (const [status, mediaType, kind] of bodyKindsOf(entry.route, entry.names)) {
425
+ kinds.set(status, [
426
+ ...(kinds.get(status) ?? []),
427
+ `${JSON.stringify(mediaType)}: ${JSON.stringify(kind)}`,
428
+ ]);
429
+ }
430
+ const bodies = kinds.size === 0
423
431
  ? ""
424
- : `, bodies: { ${kinds.map(([status, kind]) => `${JSON.stringify(status)}: ${JSON.stringify(kind)}`).join(", ")} }`;
432
+ : `, bodies: { ${[...kinds].map(([status, byType]) => `${JSON.stringify(status)}: { ${byType.join(", ")} }`).join(", ")} }`;
425
433
  const declared = `{ arms: ${entry.names.responses}, outputSchema: ${published ?? "undefined"}${bodies} }`;
426
434
  /**
427
435
  * **A parameterless tool must not DECLARE an argument it never reads.**
@@ -432,7 +440,13 @@ export function renderTools(entries, declaredOperationIds, options) {
432
440
  * omission is deliberate.
433
441
  */
434
442
  const parameterless = intersectedValidators(entry).length === 0 && entry.route.bodyProperty === undefined;
435
- const argument = parameterless ? "{}" : "{ ...args }";
443
+ // The selected `accept` last, so a caller's own `accept` argument cannot replace it.
444
+ const supplied = selectedAcceptOf(entry);
445
+ const fields = [
446
+ ...(parameterless ? [] : ["...args"]),
447
+ ...(supplied === undefined ? [] : [supplied]),
448
+ ];
449
+ const argument = fields.length === 0 ? "{}" : `{ ${fields.join(", ")} }`;
436
450
  const argumentName = parameterless ? "_args" : "args";
437
451
  return ` {
438
452
  const tool = server.registerTool(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "typespec-http-mcp",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
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
5
  "keywords": [
6
6
  "cloudflare-workers",
@@ -45,29 +45,29 @@
45
45
  "provenance": true
46
46
  },
47
47
  "dependencies": {
48
- "typespec-http-zod": "^0.26.0"
48
+ "typespec-http-zod": "^0.28.0"
49
49
  },
50
50
  "devDependencies": {
51
51
  "@hono/zod-validator": "^0.9.1",
52
52
  "@modelcontextprotocol/client": "2.0.0",
53
53
  "@modelcontextprotocol/server": "2.0.0",
54
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",
55
+ "@typespec/compiler": "1.17.0",
56
+ "@typespec/events": "0.87.0",
57
+ "@typespec/http": "1.17.0",
58
+ "@typespec/http-specs": "0.1.0-alpha.44",
59
+ "@typespec/openapi": "1.17.0",
60
+ "@typespec/openapi3": "1.17.0",
61
+ "@typespec/rest": "0.87.0",
62
+ "@typespec/sse": "0.87.0",
63
+ "@typespec/streams": "0.87.0",
64
+ "@typespec/versioning": "0.87.0",
65
+ "@typespec/xml": "0.87.0",
66
66
  "hono": "^4.13.7",
67
67
  "oxfmt": "^0.67.0",
68
68
  "oxlint": "^1.82.0",
69
69
  "typescript": "~7.0.2",
70
- "typespec-hono": "0.23.0",
70
+ "typespec-hono": "0.25.0",
71
71
  "typespec-http-mcp": "link:.",
72
72
  "vitest": "^5.0.0",
73
73
  "wrangler": "^4.131.1",
@@ -77,6 +77,7 @@
77
77
  "@modelcontextprotocol/server": "^2.0.0",
78
78
  "@typespec/compiler": "^1.15.0",
79
79
  "@typespec/http": "^1.15.0",
80
+ "@typespec/openapi": "^1.15.0",
80
81
  "zod": "^4.5.0"
81
82
  },
82
83
  "engines": {
@@ -84,7 +85,7 @@
84
85
  },
85
86
  "tspMain": "lib/main.tsp",
86
87
  "scripts": {
87
- "build": "tsc -p tsconfig.build.json",
88
+ "build": "node --eval \"require('node:fs').rmSync('dist', { recursive: true, force: true })\" && tsc -p tsconfig.build.json",
88
89
  "test": "tsc -p tsconfig.build.json && vitest run",
89
90
  "test:unit": "tsc -p tsconfig.build.json && vitest run --project unit",
90
91
  "typecheck": "tsc -p tsconfig.json",