typespec-http-mcp 0.1.0 → 0.3.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/README.md +4 -1
- package/dist/src/bodies.d.ts +16 -34
- package/dist/src/bodies.js +13 -78
- package/dist/src/emitter.js +9 -11
- package/dist/src/lib.d.ts +0 -3
- package/dist/src/lib.js +1 -17
- package/dist/src/refusals.d.ts +0 -3
- package/dist/src/refusals.js +7 -18
- package/dist/src/runtime.d.ts +39 -10
- package/dist/src/runtime.js +98 -29
- package/dist/src/select.d.ts +5 -10
- package/dist/src/select.js +5 -31
- package/dist/src/tools.js +114 -82
- package/package.json +16 -15
- package/src/runtime.ts +156 -57
- package/dist/src/statuses.d.ts +0 -35
- package/dist/src/statuses.js +0 -29
package/dist/src/select.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type Program } from "@typespec/compiler";
|
|
2
|
-
import type { HttpOperation
|
|
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
|
-
*
|
|
43
|
-
*
|
|
44
|
-
*
|
|
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
|
|
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;
|
package/dist/src/select.js
CHANGED
|
@@ -1,40 +1,14 @@
|
|
|
1
1
|
import { getAllTags } from "@typespec/compiler";
|
|
2
2
|
/**
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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 =
|
|
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, literal, objectKey, statusCodesOf, } from "typespec-http-zod";
|
|
2
2
|
import { annotationsFor } from "./annotations.js";
|
|
3
|
-
import { bodyKindsOf,
|
|
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
|
*
|
|
@@ -15,7 +14,7 @@ import { CONTENTLESS_STATUS_CODES, STATUS_GROUPS } from "./statuses.js";
|
|
|
15
14
|
function renderSecurity(requirements) {
|
|
16
15
|
return `[${requirements
|
|
17
16
|
.map((requirement) => `{ ${Object.entries(requirement)
|
|
18
|
-
.map(([scheme, scopes]) => `${
|
|
17
|
+
.map(([scheme, scopes]) => `${literal(scheme)}: [${scopes.map((s) => literal(s)).join(", ")}]`)
|
|
19
18
|
.join(", ")} }`)
|
|
20
19
|
.join(", ")}]`;
|
|
21
20
|
}
|
|
@@ -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,
|
|
43
|
-
* identity of
|
|
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] ?
|
|
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.
|
|
55
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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)}: ${literal(selected.value)}`;
|
|
166
|
+
}
|
|
139
167
|
/**
|
|
140
168
|
* The tool's ARGUMENT schema: every group the document declares, merged into one object.
|
|
141
169
|
*
|
|
@@ -154,65 +182,46 @@ function inputTypeOf(entry) {
|
|
|
154
182
|
function inputSchemaOf(entry, sealed) {
|
|
155
183
|
const constructor = sealed ? "z.strictObject" : "z.looseObject";
|
|
156
184
|
const spreads = shapedValidators(entry).map((name) => `...${name}.shape`);
|
|
185
|
+
/**
|
|
186
|
+
* **A decoded path parameter takes its VALUE schema, not the path validator's field.** That field
|
|
187
|
+
* is `z.preprocess(<decode>, <value schema>)`: it reads the segment TEXT a router extracts -
|
|
188
|
+
* `primitive.a` for `{.param}` - and hands on `a`. A tool argument is already the value, so through
|
|
189
|
+
* the decoder `{ param: "a" }` was refused as not the expansion the route declares, while
|
|
190
|
+
* `tools/list` published `param` as a plain string, and a value starting with the route's literal
|
|
191
|
+
* was rewritten without an error (issue #1). Six `@typespec/http-specs` path scenarios failed.
|
|
192
|
+
*
|
|
193
|
+
* Which parameters are decoded is the library's fact, `decodedPathParameters`, read rather than
|
|
194
|
+
* re-derived, so this cannot drift from the validator it overrides. The override comes after the
|
|
195
|
+
* spread, so it wins.
|
|
196
|
+
*/
|
|
197
|
+
const path = entry.names.path;
|
|
198
|
+
if (path !== undefined) {
|
|
199
|
+
for (const name of entry.route.decodedPathParameters) {
|
|
200
|
+
spreads.push(`${objectKey(name)}: ${path}.shape[${literal(name)}].out`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
157
203
|
if (entry.route.bodyProperty !== undefined && entry.names.body !== undefined) {
|
|
158
|
-
|
|
204
|
+
/**
|
|
205
|
+
* **Optional where the document lets the body be absent.** It was required here while the
|
|
206
|
+
* handler's input said \`body?:\`, so a tool call omitting a body the document makes optional
|
|
207
|
+
* was refused before it reached the handler.
|
|
208
|
+
*/
|
|
209
|
+
const optional = entry.route.optionalBody ? ".exactOptional()" : "";
|
|
210
|
+
spreads.push(`${objectKey(entry.route.bodyProperty)}: ${entry.names.body}${optional}`);
|
|
159
211
|
}
|
|
160
212
|
if (spreads.length === 0)
|
|
161
213
|
return "z.strictObject({})";
|
|
162
214
|
return `${constructor}({ ${spreads.join(", ")} })`;
|
|
163
215
|
}
|
|
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
216
|
/**
|
|
183
|
-
*
|
|
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.
|
|
217
|
+
* The status a result member is typed with: the codes its response answers with, written out.
|
|
195
218
|
*
|
|
196
|
-
* **
|
|
197
|
-
*
|
|
219
|
+
* **Written out, never as Hono's group types**, because this file imports nothing from Hono. The
|
|
220
|
+
* codes are the library's `statusCodesOf`, the same rule the server emitter and the contract types
|
|
221
|
+
* read, so a tool's result and a handler's result agree member by member.
|
|
198
222
|
*/
|
|
199
223
|
function statusTypeOf(response, route) {
|
|
200
|
-
const
|
|
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);
|
|
224
|
+
const codes = statusCodesOf(response, route.responses, (candidate) => candidate.schema !== undefined);
|
|
216
225
|
return codes.length === 0 ? "never" : codes.join(" | ");
|
|
217
226
|
}
|
|
218
227
|
/**
|
|
@@ -226,23 +235,28 @@ function resultTypeOf(entry) {
|
|
|
226
235
|
const { response } = member;
|
|
227
236
|
const fields = [`readonly status: ${statusTypeOf(response, entry.route)}`];
|
|
228
237
|
if (member.choosesMediaType && member.mediaType !== undefined) {
|
|
229
|
-
fields.push(`readonly contentType: ${
|
|
238
|
+
fields.push(`readonly contentType: ${mediaTypeTypeOf(member.mediaType)}`);
|
|
230
239
|
}
|
|
231
|
-
switch (
|
|
240
|
+
switch (member.serving) {
|
|
232
241
|
case "none":
|
|
233
242
|
fields.push("readonly body?: undefined");
|
|
234
243
|
break;
|
|
235
244
|
case "stream":
|
|
236
|
-
fields.push("readonly body:
|
|
245
|
+
fields.push("readonly body: ByteStream");
|
|
237
246
|
break;
|
|
238
247
|
case "binary":
|
|
239
|
-
|
|
248
|
+
/**
|
|
249
|
+
* **`ByteStream`, not `ReadableStream`, so a backend's contract value fits**, as the sibling
|
|
250
|
+
* server types it. The contract types state a binary body structurally, because they cannot
|
|
251
|
+
* name the platform stream; a platform stream still satisfies it.
|
|
252
|
+
*/
|
|
253
|
+
fields.push("readonly body: ByteStream | Uint8Array<ArrayBuffer> | ArrayBuffer");
|
|
240
254
|
break;
|
|
241
255
|
case "unvalidated":
|
|
242
256
|
fields.push("readonly body: string");
|
|
243
257
|
break;
|
|
244
258
|
default:
|
|
245
|
-
fields.push(`readonly body: Produced<z.
|
|
259
|
+
fields.push(`readonly body: Produced<z.input<typeof ${member.schemaName}>>`);
|
|
246
260
|
}
|
|
247
261
|
if (response.headers.length > 0) {
|
|
248
262
|
/**
|
|
@@ -328,7 +342,7 @@ export function renderTools(entries, declaredOperationIds, options) {
|
|
|
328
342
|
if (entry.names.body !== undefined)
|
|
329
343
|
imported.add(entry.names.body);
|
|
330
344
|
for (const member of membersOf(entry.route, entry.names)) {
|
|
331
|
-
const serving =
|
|
345
|
+
const serving = member.serving;
|
|
332
346
|
if ((serving === "json" || serving === "text") && member.schemaName !== undefined) {
|
|
333
347
|
imported.add(member.schemaName);
|
|
334
348
|
}
|
|
@@ -349,7 +363,13 @@ export function renderTools(entries, declaredOperationIds, options) {
|
|
|
349
363
|
const runtimeTypes = ["McpServer", "ServerContext", "ToolDeps"];
|
|
350
364
|
if (entries.length > 0)
|
|
351
365
|
runtimeTypes.push("Awaitable");
|
|
352
|
-
const
|
|
366
|
+
const usesByteStream = entries.some((entry) => membersOf(entry.route, entry.names).some((member) => {
|
|
367
|
+
const serving = member.serving;
|
|
368
|
+
return serving === "binary" || serving === "stream";
|
|
369
|
+
}));
|
|
370
|
+
if (usesByteStream)
|
|
371
|
+
runtimeTypes.push("ByteStream");
|
|
372
|
+
const from = literal(DEFAULT_RUNTIME_MODULE);
|
|
353
373
|
const runtimeImport = [
|
|
354
374
|
...(entries.length > 0 ? [`import { toolResult } from ${from};`] : []),
|
|
355
375
|
`import type { ${runtimeTypes.toSorted().join(", ")} } from ${from};`,
|
|
@@ -364,7 +384,7 @@ export function renderTools(entries, declaredOperationIds, options) {
|
|
|
364
384
|
*/
|
|
365
385
|
const usesFields = entries.some((entry) => intersectedValidators(entry).length > 0);
|
|
366
386
|
const usesProduced = entries.some((entry) => membersOf(entry.route, entry.names).some((member) => {
|
|
367
|
-
const serving =
|
|
387
|
+
const serving = member.serving;
|
|
368
388
|
return serving === "json" || serving === "text";
|
|
369
389
|
}));
|
|
370
390
|
const helpers = `${usesFields ? FIELDS_HELPER : ""}${usesProduced ? PRODUCED_HELPER : ""}`;
|
|
@@ -394,10 +414,10 @@ export function renderTools(entries, declaredOperationIds, options) {
|
|
|
394
414
|
.join("\n");
|
|
395
415
|
/** Every scope any selected operation demands, de-duplicated and ordered. */
|
|
396
416
|
const scopes = [...new Set(entries.flatMap((entry) => [...entry.route.scopes]))].toSorted();
|
|
397
|
-
const scopesLiteral = `[${scopes.map((scope) =>
|
|
417
|
+
const scopesLiteral = `[${scopes.map((scope) => literal(scope)).join(", ")}]`;
|
|
398
418
|
const declaredUnion = declaredOperationIds.length === 0
|
|
399
419
|
? "never"
|
|
400
|
-
: declaredOperationIds.map((id) =>
|
|
420
|
+
: declaredOperationIds.map((id) => literal(id)).join(" | ");
|
|
401
421
|
/**
|
|
402
422
|
* **Registered in a deterministic order, sorted by tool name.**
|
|
403
423
|
*
|
|
@@ -408,20 +428,26 @@ export function renderTools(entries, declaredOperationIds, options) {
|
|
|
408
428
|
const ordered = [...entries].toSorted((a, b) => a.toolName.localeCompare(b.toolName));
|
|
409
429
|
const registrations = ordered
|
|
410
430
|
.map((entry) => {
|
|
411
|
-
const title = entry.title === undefined ? "" : `\n\t\t\t\ttitle: ${
|
|
431
|
+
const title = entry.title === undefined ? "" : `\n\t\t\t\ttitle: ${literal(entry.title)},`;
|
|
412
432
|
const description = entry.description === undefined
|
|
413
433
|
? ""
|
|
414
|
-
: `\n\t\t\t\tdescription: ${
|
|
434
|
+
: `\n\t\t\t\tdescription: ${literal(entry.description)},`;
|
|
415
435
|
const published = outputSchemaOf(entry);
|
|
416
436
|
const outputSchema = published === undefined ? "" : `\n\t\t\t\toutputSchema: ${published},`;
|
|
417
437
|
/**
|
|
418
438
|
* The body kinds the runtime cannot read off an arm, only where one applies: a JSON body is
|
|
419
439
|
* known from its media type, and most operations answer nothing else.
|
|
420
440
|
*/
|
|
421
|
-
const kinds =
|
|
422
|
-
const
|
|
441
|
+
const kinds = new Map();
|
|
442
|
+
for (const [status, mediaType, kind] of bodyKindsOf(entry.route, entry.names)) {
|
|
443
|
+
kinds.set(status, [
|
|
444
|
+
...(kinds.get(status) ?? []),
|
|
445
|
+
`${literal(mediaType)}: ${literal(kind)}`,
|
|
446
|
+
]);
|
|
447
|
+
}
|
|
448
|
+
const bodies = kinds.size === 0
|
|
423
449
|
? ""
|
|
424
|
-
: `, bodies: { ${kinds.map(([status,
|
|
450
|
+
: `, bodies: { ${[...kinds].map(([status, byType]) => `${literal(status)}: { ${byType.join(", ")} }`).join(", ")} }`;
|
|
425
451
|
const declared = `{ arms: ${entry.names.responses}, outputSchema: ${published ?? "undefined"}${bodies} }`;
|
|
426
452
|
/**
|
|
427
453
|
* **A parameterless tool must not DECLARE an argument it never reads.**
|
|
@@ -432,27 +458,33 @@ export function renderTools(entries, declaredOperationIds, options) {
|
|
|
432
458
|
* omission is deliberate.
|
|
433
459
|
*/
|
|
434
460
|
const parameterless = intersectedValidators(entry).length === 0 && entry.route.bodyProperty === undefined;
|
|
435
|
-
|
|
461
|
+
// The selected `accept` last, so a caller's own `accept` argument cannot replace it.
|
|
462
|
+
const supplied = selectedAcceptOf(entry);
|
|
463
|
+
const fields = [
|
|
464
|
+
...(parameterless ? [] : ["...args"]),
|
|
465
|
+
...(supplied === undefined ? [] : [supplied]),
|
|
466
|
+
];
|
|
467
|
+
const argument = fields.length === 0 ? "{}" : `{ ${fields.join(", ")} }`;
|
|
436
468
|
const argumentName = parameterless ? "_args" : "args";
|
|
437
469
|
return ` {
|
|
438
470
|
const tool = server.registerTool(
|
|
439
|
-
${
|
|
471
|
+
${literal(entry.toolName)},
|
|
440
472
|
{${title}${description}
|
|
441
473
|
inputSchema: ${inputSchemaName(entry)},${outputSchema}
|
|
442
474
|
annotations: ${renderAnnotations(annotationsFor(entry.route.verb))},
|
|
443
475
|
},
|
|
444
476
|
async (${argumentName}, context) => {
|
|
445
|
-
const ctx = deps.context(context, ${
|
|
477
|
+
const ctx = deps.context(context, ${literal(entry.route.authentication)});
|
|
446
478
|
if (ctx === null) return deps.noContext(context);
|
|
447
479
|
return toolResult(
|
|
448
480
|
${declared},
|
|
449
|
-
${
|
|
481
|
+
${literal(entry.route.operationId)},
|
|
450
482
|
await handlersFor(context).${entry.route.operationId}(ctx, ${argument}),
|
|
451
483
|
);
|
|
452
484
|
},
|
|
453
485
|
);
|
|
454
486
|
if (deps.permits(${renderSecurity(entry.requirements)})) {
|
|
455
|
-
registered.push(${
|
|
487
|
+
registered.push(${literal(entry.toolName)});
|
|
456
488
|
} else {
|
|
457
489
|
tool.remove();
|
|
458
490
|
}
|
|
@@ -517,7 +549,7 @@ export type ToolsExhaustive<T> = T & Record<Exclude<keyof T, DeclaredOperationId
|
|
|
517
549
|
export const SCOPES_SUPPORTED = ${scopesLiteral} as const;
|
|
518
550
|
|
|
519
551
|
/** Every tool this file registers, in the order it registers them. */
|
|
520
|
-
export const TOOL_NAMES = [${ordered.map((entry) =>
|
|
552
|
+
export const TOOL_NAMES = [${ordered.map((entry) => literal(entry.toolName)).join(", ")}] as const;
|
|
521
553
|
|
|
522
554
|
/**
|
|
523
555
|
* Register every tool this service declares, onto a server the APPLICATION owns.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-http-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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.
|
|
48
|
+
"typespec-http-zod": "^0.29.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.
|
|
56
|
-
"@typespec/events": "0.
|
|
57
|
-
"@typespec/http": "1.
|
|
58
|
-
"@typespec/http-specs": "0.1.0-alpha.
|
|
59
|
-
"@typespec/openapi": "1.
|
|
60
|
-
"@typespec/openapi3": "1.
|
|
61
|
-
"@typespec/rest": "0.
|
|
62
|
-
"@typespec/sse": "0.
|
|
63
|
-
"@typespec/streams": "0.
|
|
64
|
-
"@typespec/versioning": "0.
|
|
65
|
-
"@typespec/xml": "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.
|
|
70
|
+
"typespec-hono": "0.26.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",
|