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/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/src/annotations.d.ts +33 -0
- package/dist/src/annotations.js +41 -0
- package/dist/src/bodies.d.ts +69 -0
- package/dist/src/bodies.js +94 -0
- package/dist/src/coexistence.d.ts +13 -0
- package/dist/src/coexistence.js +91 -0
- package/dist/src/emitter.d.ts +15 -0
- package/dist/src/emitter.js +237 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/lib.d.ts +119 -0
- package/dist/src/lib.js +278 -0
- package/dist/src/names.d.ts +34 -0
- package/dist/src/names.js +55 -0
- package/dist/src/refusals.d.ts +52 -0
- package/dist/src/refusals.js +167 -0
- package/dist/src/runtime.d.ts +191 -0
- package/dist/src/runtime.js +280 -0
- package/dist/src/select.d.ts +49 -0
- package/dist/src/select.js +59 -0
- package/dist/src/statuses.d.ts +35 -0
- package/dist/src/statuses.js +29 -0
- package/dist/src/tools.d.ts +37 -0
- package/dist/src/tools.js +545 -0
- package/dist/src/tsp-index.d.ts +14 -0
- package/dist/src/tsp-index.js +14 -0
- package/lib/main.tsp +3 -0
- package/package.json +95 -0
- package/src/runtime.ts +475 -0
|
@@ -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
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
|
+
}
|