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,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The arm that answers for a status, in the document's own precedence order.
|
|
3
|
+
*
|
|
4
|
+
* Exact code, then the `NXX` range, then `default`. Sorting the rule out once here is what stops
|
|
5
|
+
* every application re-deriving it, and getting it silently wrong when a `404` and a `4XX` both apply.
|
|
6
|
+
*/
|
|
7
|
+
export function armFor(arms, status) {
|
|
8
|
+
return (arms.find((arm) => arm.status === status) ??
|
|
9
|
+
arms.find((arm) => arm.status === `${Math.floor(status / 100)}XX`) ??
|
|
10
|
+
arms.find((arm) => arm.status === "default"));
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* A response body that does not match the schema the document publishes for its status.
|
|
14
|
+
*
|
|
15
|
+
* **Thrown, so the application decides what a contract failure answers with**, the same class and
|
|
16
|
+
* the same fields as the sibling server emitter's. The MCP server turns a thrown error into a
|
|
17
|
+
* tool-execution error; an application that wants something else catches it around `registerTools`'
|
|
18
|
+
* handlers.
|
|
19
|
+
*
|
|
20
|
+
* `issues` are Zod's, so they carry paths and codes. They also carry the offending VALUES; an
|
|
21
|
+
* application that logs them should decide whether its results may contain anything it would not
|
|
22
|
+
* log.
|
|
23
|
+
*/
|
|
24
|
+
export class ResponseContractError extends Error {
|
|
25
|
+
operationId;
|
|
26
|
+
status;
|
|
27
|
+
issues;
|
|
28
|
+
constructor(operationId, status, issues) {
|
|
29
|
+
super(`${operationId} answered ${status} with a body its document does not permit`);
|
|
30
|
+
this.operationId = operationId;
|
|
31
|
+
this.status = status;
|
|
32
|
+
this.issues = issues;
|
|
33
|
+
this.name = "ResponseContractError";
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* A handler answered with a status its operation does not declare.
|
|
38
|
+
*
|
|
39
|
+
* **Unreachable from a typed handler.** The generated `Tools` interface types every result as the
|
|
40
|
+
* union of the declared statuses, so a literal outside it does not compile. This is what a CAST
|
|
41
|
+
* produces, or a result that crossed a boundary the type system cannot see into. Thrown rather than
|
|
42
|
+
* answered, because answering would publish a status the contract does not state.
|
|
43
|
+
*/
|
|
44
|
+
export class UndeclaredStatusError extends Error {
|
|
45
|
+
operationId;
|
|
46
|
+
result;
|
|
47
|
+
constructor(operationId, result) {
|
|
48
|
+
const status = typeof result === "object" && result !== null && "status" in result
|
|
49
|
+
? String(result.status)
|
|
50
|
+
: "no status";
|
|
51
|
+
super(`${operationId} answered ${status}, which its document does not declare`);
|
|
52
|
+
this.operationId = operationId;
|
|
53
|
+
this.result = result;
|
|
54
|
+
this.name = "UndeclaredStatusError";
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/** A media type whose body is a JSON value. The rule the library and the server emitter apply. */
|
|
58
|
+
function isJsonMediaType(type) {
|
|
59
|
+
return /^application\/(json|.*\+json)$/.test(type);
|
|
60
|
+
}
|
|
61
|
+
/** RFC 9110 `token`, less `*`, which names a range rather than a type. */
|
|
62
|
+
const MEDIA_TOKEN = "[!#$%&'+.^_`|~0-9A-Za-z-]+";
|
|
63
|
+
const SERVED_MEDIA_TYPE = new RegExp(`^(${MEDIA_TOKEN})/${MEDIA_TOKEN}\\s*(;.*)?$`);
|
|
64
|
+
/**
|
|
65
|
+
* Whether an offered media type, which may be a range such as `image/*`, admits a served one.
|
|
66
|
+
*
|
|
67
|
+
* The sibling server emitter's `mediaTypeWithin`, rule for rule: a range admits a concrete type inside
|
|
68
|
+
* it, never its own spelling or a type with no subtype; type names compare case-insensitively
|
|
69
|
+
* (RFC 9110 section 8.3.1), and parameters such as `charset` are allowed.
|
|
70
|
+
*/
|
|
71
|
+
function admits(offered, served) {
|
|
72
|
+
if (!offered.endsWith("/*"))
|
|
73
|
+
return offered === served;
|
|
74
|
+
const type = SERVED_MEDIA_TYPE.exec(served)?.[1];
|
|
75
|
+
if (type === undefined)
|
|
76
|
+
return false;
|
|
77
|
+
if (offered === "*/*")
|
|
78
|
+
return true;
|
|
79
|
+
return type.toLowerCase() === offered.slice(0, -2).toLowerCase();
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* The media type a result is served as, or `undefined` where the arm names none.
|
|
83
|
+
*
|
|
84
|
+
* Where the arm offers several, or only a wildcard range, the handler names the one it answers with,
|
|
85
|
+
* and a type the arm does not offer is as undeclared as a status it does not. A range admits every
|
|
86
|
+
* type inside it: `image/*` is what lets a handler answer `image/png`.
|
|
87
|
+
*/
|
|
88
|
+
function mediaTypeOf(arm, contentType, operationId, result) {
|
|
89
|
+
const offered = arm.contentTypes ?? [];
|
|
90
|
+
const choosing = offered.length > 1 || offered.some((type) => type.endsWith("/*"));
|
|
91
|
+
if (!choosing)
|
|
92
|
+
return offered[0];
|
|
93
|
+
if (typeof contentType === "string" && offered.some((type) => admits(type, contentType))) {
|
|
94
|
+
return contentType;
|
|
95
|
+
}
|
|
96
|
+
throw new UndeclaredStatusError(operationId, result);
|
|
97
|
+
}
|
|
98
|
+
/** A body that is not what its arm says it is, as one issue in Zod's own shape. */
|
|
99
|
+
function refusedBody(input, message) {
|
|
100
|
+
return [{ code: "custom", path: [], message, input }];
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* The bytes of a binary body, which a handler may hand over in any of the forms the result type admits.
|
|
104
|
+
*
|
|
105
|
+
* A stream is buffered: a tool result is one message, and the block carries the whole of it.
|
|
106
|
+
* `Response` reads it, because both runtimes this output runs on, Node and workerd, provide it.
|
|
107
|
+
*/
|
|
108
|
+
async function bytesOf(body) {
|
|
109
|
+
if (body instanceof Uint8Array)
|
|
110
|
+
return body;
|
|
111
|
+
if (body instanceof ArrayBuffer)
|
|
112
|
+
return new Uint8Array(body);
|
|
113
|
+
if (body instanceof ReadableStream)
|
|
114
|
+
return new Uint8Array(await new Response(body).arrayBuffer());
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
117
|
+
/** Base64, in chunks, so a large body does not exceed the argument limit of one call. */
|
|
118
|
+
function base64Of(bytes) {
|
|
119
|
+
let binary = "";
|
|
120
|
+
for (let offset = 0; offset < bytes.length; offset += 0x8000) {
|
|
121
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000));
|
|
122
|
+
}
|
|
123
|
+
return btoa(binary);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The MCP result for what a handler returned: `{ status, body, headers }` for a response the
|
|
127
|
+
* document declares.
|
|
128
|
+
*
|
|
129
|
+
* **In the runtime rather than in `deps`, on the same test the sibling emitter applies**: every rule
|
|
130
|
+
* below is published -- by the document (the arms, their bodies, their headers) or by the MCP
|
|
131
|
+
* specification (the result shape, its content blocks, `isError`, the text mirror). An application
|
|
132
|
+
* forced to supply this would be re-implementing two standards, and getting either wrong differently
|
|
133
|
+
* from everybody else.
|
|
134
|
+
*
|
|
135
|
+
* **What is answered is the PARSED body, not the one the handler returned**, as on the HTTP surface:
|
|
136
|
+
* a schema that strips undeclared keys strips them here too, so a field the document does not publish
|
|
137
|
+
* does not reach a model.
|
|
138
|
+
*
|
|
139
|
+
* Asynchronous because a binary body may be a stream, which is read to its end before it is answered.
|
|
140
|
+
*/
|
|
141
|
+
export async function toolResult(declared, operationId, result) {
|
|
142
|
+
const answer = typeof result === "object" && result !== null ? result : {};
|
|
143
|
+
const { status } = answer;
|
|
144
|
+
const arm = typeof status === "number" ? armFor(declared.arms, status) : undefined;
|
|
145
|
+
if (arm === undefined || typeof status !== "number") {
|
|
146
|
+
throw new UndeclaredStatusError(operationId, result);
|
|
147
|
+
}
|
|
148
|
+
const mediaType = mediaTypeOf(arm, answer.contentType, operationId, result);
|
|
149
|
+
/**
|
|
150
|
+
* **`isError` is decided from the STATUS, not from whether the arm is an error model.**
|
|
151
|
+
*
|
|
152
|
+
* MCP's `isError` marks a tool-execution error the model can self-correct from, which is exactly
|
|
153
|
+
* what a declared 4xx or 5xx is: the service's own published failure, with a body the model can
|
|
154
|
+
* read. It is decided from the status because `armFor` may resolve to a range or `default`, whose
|
|
155
|
+
* numeric status only the result carries.
|
|
156
|
+
*/
|
|
157
|
+
const isError = status >= 400;
|
|
158
|
+
const toolAnswer = { content: [] };
|
|
159
|
+
/** Whether the body was answered as JSON text, which `structuredContent` then mirrors. */
|
|
160
|
+
let servedAsJson = false;
|
|
161
|
+
/**
|
|
162
|
+
* A bodyless arm carries nothing a model can read, and inventing a confirmation sentence would
|
|
163
|
+
* state something the document does not, so `content` stays `[]`: present, an array, and honest.
|
|
164
|
+
*/
|
|
165
|
+
if (arm.schema !== undefined && answer.body !== undefined) {
|
|
166
|
+
const kind = declared.bodies?.[String(arm.status)];
|
|
167
|
+
if (mediaType === undefined || isJsonMediaType(mediaType)) {
|
|
168
|
+
/**
|
|
169
|
+
* **Validated wherever the body is served as JSON**, which is also the only form a tool result
|
|
170
|
+
* can carry structurally. An arm naming no media type is a hand-written arm, and a tool
|
|
171
|
+
* result's text is JSON, so it is validated too.
|
|
172
|
+
*/
|
|
173
|
+
const parsed = arm.schema.safeParse(answer.body);
|
|
174
|
+
if (!parsed.success) {
|
|
175
|
+
throw new ResponseContractError(operationId, status, parsed.error.issues);
|
|
176
|
+
}
|
|
177
|
+
servedAsJson = true;
|
|
178
|
+
// The text mirror is kept even beside `structuredContent`: MCP recommends it for clients
|
|
179
|
+
// that read only `content`.
|
|
180
|
+
toolAnswer.content = [{ type: "text", text: JSON.stringify(parsed.data) }];
|
|
181
|
+
/**
|
|
182
|
+
* **Where this arm's schema IS the published `outputSchema`.** MCP obliges a tool that
|
|
183
|
+
* declares an `outputSchema` to return conforming `structuredContent`, and a failure's body
|
|
184
|
+
* does not conform to the success schema the tool published.
|
|
185
|
+
*/
|
|
186
|
+
if (declared.outputSchema !== undefined && arm.schema === declared.outputSchema) {
|
|
187
|
+
toolAnswer.structuredContent = parsed.data;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
else if (kind === "text") {
|
|
191
|
+
/**
|
|
192
|
+
* **A string body under a media type that is not JSON IS the text**, so it is checked against
|
|
193
|
+
* its schema exactly as the sibling server emitter checks it, and the parsed string answered.
|
|
194
|
+
*/
|
|
195
|
+
const parsed = arm.schema.safeParse(answer.body);
|
|
196
|
+
if (!parsed.success) {
|
|
197
|
+
throw new ResponseContractError(operationId, status, parsed.error.issues);
|
|
198
|
+
}
|
|
199
|
+
toolAnswer.content = [{ type: "text", text: String(parsed.data) }];
|
|
200
|
+
}
|
|
201
|
+
else if (kind === "binary") {
|
|
202
|
+
/**
|
|
203
|
+
* **Raw bytes are answered as the block MCP defines for their media type**, carrying the data
|
|
204
|
+
* as base64 and the media type served. The emitter refuses every other binary media type, so
|
|
205
|
+
* one reaching here was declared by hand.
|
|
206
|
+
*/
|
|
207
|
+
const block = /^(image|audio)\//.exec(mediaType)?.[1];
|
|
208
|
+
if (block !== "image" && block !== "audio") {
|
|
209
|
+
throw new ResponseContractError(operationId, status, refusedBody(answer.body, `no MCP content block carries a binary body as ${mediaType}`));
|
|
210
|
+
}
|
|
211
|
+
const bytes = await bytesOf(answer.body);
|
|
212
|
+
if (bytes === undefined) {
|
|
213
|
+
throw new ResponseContractError(operationId, status, refusedBody(answer.body, "a binary body is a Uint8Array, an ArrayBuffer or a ReadableStream"));
|
|
214
|
+
}
|
|
215
|
+
toolAnswer.content = [{ type: block, data: base64Of(bytes), mimeType: mediaType }];
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
/**
|
|
219
|
+
* **A model under a media type that is not JSON is text the handler serialised**, which the
|
|
220
|
+
* schema does not describe, so it is answered as it is. `unvalidated-response-media-type`
|
|
221
|
+
* said so at compile time. Anything but a string is not that text.
|
|
222
|
+
*/
|
|
223
|
+
if (typeof answer.body !== "string") {
|
|
224
|
+
throw new ResponseContractError(operationId, status, refusedBody(answer.body, `a body answered as ${mediaType} is the text the handler supplies`));
|
|
225
|
+
}
|
|
226
|
+
toolAnswer.content = [{ type: "text", text: answer.body }];
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* **A success the published `outputSchema` does not describe fails here, by name.**
|
|
231
|
+
*
|
|
232
|
+
* The SDK refuses a result that is not an error and carries no `structuredContent` once a tool
|
|
233
|
+
* declares an `outputSchema`. The emitter publishes one only where every 2xx and 3xx response the
|
|
234
|
+
* document declares shares its body, but `default` covers the statuses nobody listed, 2xx and 3xx
|
|
235
|
+
* among them. Such a success is answered only if its body also meets the published schema;
|
|
236
|
+
* otherwise it is the contract failure it is, rather than the SDK's "no structured content".
|
|
237
|
+
*
|
|
238
|
+
* **The body the HANDLER returned is checked, not the one its own arm parsed.** An object schema
|
|
239
|
+
* strips the keys it does not declare, so a `Problem`-parsed body has already lost every key a
|
|
240
|
+
* `Widget` needs: checking that would refuse a body that meets both. The text mirror is then the
|
|
241
|
+
* published schema's parse, because that is what `structuredContent` carries.
|
|
242
|
+
*/
|
|
243
|
+
if (!isError &&
|
|
244
|
+
declared.outputSchema !== undefined &&
|
|
245
|
+
toolAnswer.structuredContent === undefined) {
|
|
246
|
+
const conforming = declared.outputSchema.safeParse(answer.body);
|
|
247
|
+
if (!conforming.success) {
|
|
248
|
+
throw new ResponseContractError(operationId, status, conforming.error.issues);
|
|
249
|
+
}
|
|
250
|
+
toolAnswer.structuredContent = conforming.data;
|
|
251
|
+
if (servedAsJson) {
|
|
252
|
+
toolAnswer.content = [{ type: "text", text: JSON.stringify(conforming.data) }];
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
if (isError)
|
|
256
|
+
toolAnswer.isError = true;
|
|
257
|
+
/**
|
|
258
|
+
* **Response headers go to `_meta`, keyed by the WIRE name, never to `structuredContent`.**
|
|
259
|
+
*
|
|
260
|
+
* Putting them in `structuredContent` breaks the declared `outputSchema`, which MCP forbids;
|
|
261
|
+
* dropping them is a loss the consumer cannot see. `_meta` is MCP's slot for implementation data
|
|
262
|
+
* outside the tool contract, and the key is namespaced so it collides with nothing. Only the
|
|
263
|
+
* headers the arm declares are carried, and an optional one the handler left out is omitted. A
|
|
264
|
+
* model cannot read `_meta`, so a value that matters to a caller belongs in the response body --
|
|
265
|
+
* which `headers-only-success` warns about.
|
|
266
|
+
*/
|
|
267
|
+
const supplied = typeof answer.headers === "object" && answer.headers !== null
|
|
268
|
+
? answer.headers
|
|
269
|
+
: {};
|
|
270
|
+
const carried = {};
|
|
271
|
+
for (const header of arm.headers ?? []) {
|
|
272
|
+
const value = supplied[header.name];
|
|
273
|
+
if (value !== undefined)
|
|
274
|
+
carried[header.name] = value;
|
|
275
|
+
}
|
|
276
|
+
if (Object.keys(carried).length > 0) {
|
|
277
|
+
toolAnswer._meta = { "typespec-http-mcp/response-headers": carried };
|
|
278
|
+
}
|
|
279
|
+
return toolAnswer;
|
|
280
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type Program } from "@typespec/compiler";
|
|
2
|
+
import type { HttpOperation, HttpService } from "@typespec/http";
|
|
3
|
+
import type { EmittedRoute } from "typespec-http-zod";
|
|
4
|
+
/**
|
|
5
|
+
* Which operations become tools.
|
|
6
|
+
*
|
|
7
|
+
* **`getAllTags`, never `getTags`, and the difference is a silent defect.** `getTags` returns only an
|
|
8
|
+
* operation's OWN tags, so an operation declared inside a `@tag`-carrying `interface` reports none.
|
|
9
|
+
* Measured on a fixture tagging all three levels:
|
|
10
|
+
*
|
|
11
|
+
* ```
|
|
12
|
+
* GET /things getTags: ["agent"] getAllTags: ["service-level","agent"]
|
|
13
|
+
* DELETE /things/{id} getTags: [] getAllTags: ["service-level"]
|
|
14
|
+
* POST /bulk getTags: [] getAllTags: ["service-level","agent","admin"]
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* where `/bulk` is a member of a tagged interface. Tagging a whole interface is the obvious way to
|
|
18
|
+
* mark a group of operations as tools, so `getTags` would break the primary use case invisibly --
|
|
19
|
+
* the same failure shape this estate already recorded for matching operations by NAME, which never
|
|
20
|
+
* succeeded for an operation inside an `interface`.
|
|
21
|
+
*
|
|
22
|
+
* **A `@tag` on the service namespace propagates to every operation.** That is correct and
|
|
23
|
+
* documented: it is how a whole service is marked, and it is also why a tag chosen for grouping and
|
|
24
|
+
* a tag chosen for selection should not be the same word.
|
|
25
|
+
*
|
|
26
|
+
* Selection is by tag because `@typespec/openapi3` PUBLISHES tags. A reader of the document can see
|
|
27
|
+
* exactly which operations are tools, which is what keeps this out of decorator territory.
|
|
28
|
+
*/
|
|
29
|
+
export interface Selection {
|
|
30
|
+
/** Operation ids the tag configuration matched. */
|
|
31
|
+
readonly selected: readonly string[];
|
|
32
|
+
/** Every tag any operation in the service carries, for a diagnostic that has to be actionable. */
|
|
33
|
+
readonly available: readonly string[];
|
|
34
|
+
}
|
|
35
|
+
export interface SelectionOptions {
|
|
36
|
+
readonly toolTags: readonly string[] | undefined;
|
|
37
|
+
readonly excludeTags: readonly string[];
|
|
38
|
+
}
|
|
39
|
+
/** The `HttpOperation` behind each emitted route, keyed by verb and path. */
|
|
40
|
+
export declare function operationsById(service: HttpService): ReadonlyMap<string, HttpOperation>;
|
|
41
|
+
/**
|
|
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.
|
|
47
|
+
*/
|
|
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;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { getAllTags } from "@typespec/compiler";
|
|
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.
|
|
11
|
+
*/
|
|
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) {
|
|
34
|
+
const available = new Set();
|
|
35
|
+
const selected = [];
|
|
36
|
+
for (const route of routes) {
|
|
37
|
+
const operation = httpOperationFor(service, route);
|
|
38
|
+
if (operation === undefined)
|
|
39
|
+
continue;
|
|
40
|
+
const tags = getAllTags(program, operation.operation) ?? [];
|
|
41
|
+
for (const tag of tags)
|
|
42
|
+
available.add(tag);
|
|
43
|
+
if (options.excludeTags.some((tag) => tags.includes(tag)))
|
|
44
|
+
continue;
|
|
45
|
+
/**
|
|
46
|
+
* **Unset means every operation, and the alternative was rejected deliberately.** A magic tag
|
|
47
|
+
* name (`"tool"`, `"mcp"`) is a rule an author cannot see in the document and cannot discover,
|
|
48
|
+
* and it makes a correctly configured emitter produce nothing at all.
|
|
49
|
+
*
|
|
50
|
+
* **Union, not intersection.** OpenAPI tags are a flat set with no AND semantics, so "carries
|
|
51
|
+
* any of these" is the only reading the published document supports.
|
|
52
|
+
*/
|
|
53
|
+
if (options.toolTags !== undefined && !options.toolTags.some((tag) => tags.includes(tag))) {
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
selected.push(route.operationId);
|
|
57
|
+
}
|
|
58
|
+
return { selected, available: [...available].toSorted() };
|
|
59
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The status codes a handler's result may name, as values, held equal to Hono's own types.**
|
|
3
|
+
*
|
|
4
|
+
* One handler object serves both the HTTP surface `typespec-hono` generates and this tool surface,
|
|
5
|
+
* and `typespec-hono` types a result's `status` with Hono's status-code unions. This package's output
|
|
6
|
+
* must not import Hono, so it writes the same sets as literal unions, and a type the two emitters
|
|
7
|
+
* disagree on is a handler that compiles against one surface and not the other.
|
|
8
|
+
* `test/runtime/status-codes.test.ts` reads Hono's declaration file and compares every set here with
|
|
9
|
+
* it, and the agreement arm compiles the two emitted signatures against each other.
|
|
10
|
+
*/
|
|
11
|
+
/** Hono's `InfoStatusCode` to `ServerErrorStatusCode`, by the digit a range key starts with. */
|
|
12
|
+
export declare const STATUS_GROUPS: {
|
|
13
|
+
readonly 1: {
|
|
14
|
+
readonly type: "InfoStatusCode";
|
|
15
|
+
readonly codes: readonly [100, 101, 102, 103];
|
|
16
|
+
};
|
|
17
|
+
readonly 2: {
|
|
18
|
+
readonly type: "SuccessStatusCode";
|
|
19
|
+
readonly codes: readonly [200, 201, 202, 203, 204, 205, 206, 207, 208, 226];
|
|
20
|
+
};
|
|
21
|
+
readonly 3: {
|
|
22
|
+
readonly type: "RedirectStatusCode";
|
|
23
|
+
readonly codes: readonly [300, 301, 302, 303, 304, 305, 306, 307, 308];
|
|
24
|
+
};
|
|
25
|
+
readonly 4: {
|
|
26
|
+
readonly type: "ClientErrorStatusCode";
|
|
27
|
+
readonly codes: readonly [400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418, 421, 422, 423, 424, 425, 426, 428, 429, 431, 451];
|
|
28
|
+
};
|
|
29
|
+
readonly 5: {
|
|
30
|
+
readonly type: "ServerErrorStatusCode";
|
|
31
|
+
readonly codes: readonly [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511];
|
|
32
|
+
};
|
|
33
|
+
};
|
|
34
|
+
/** Hono's `ContentlessStatusCode`: a status that cannot carry a body. */
|
|
35
|
+
export declare const CONTENTLESS_STATUS_CODES: readonly [101, 204, 205, 304];
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **The status codes a handler's result may name, as values, held equal to Hono's own types.**
|
|
3
|
+
*
|
|
4
|
+
* One handler object serves both the HTTP surface `typespec-hono` generates and this tool surface,
|
|
5
|
+
* and `typespec-hono` types a result's `status` with Hono's status-code unions. This package's output
|
|
6
|
+
* must not import Hono, so it writes the same sets as literal unions, and a type the two emitters
|
|
7
|
+
* disagree on is a handler that compiles against one surface and not the other.
|
|
8
|
+
* `test/runtime/status-codes.test.ts` reads Hono's declaration file and compares every set here with
|
|
9
|
+
* it, and the agreement arm compiles the two emitted signatures against each other.
|
|
10
|
+
*/
|
|
11
|
+
/** Hono's `InfoStatusCode` to `ServerErrorStatusCode`, by the digit a range key starts with. */
|
|
12
|
+
export const STATUS_GROUPS = {
|
|
13
|
+
1: { type: "InfoStatusCode", codes: [100, 101, 102, 103] },
|
|
14
|
+
2: { type: "SuccessStatusCode", codes: [200, 201, 202, 203, 204, 205, 206, 207, 208, 226] },
|
|
15
|
+
3: { type: "RedirectStatusCode", codes: [300, 301, 302, 303, 304, 305, 306, 307, 308] },
|
|
16
|
+
4: {
|
|
17
|
+
type: "ClientErrorStatusCode",
|
|
18
|
+
codes: [
|
|
19
|
+
400, 401, 402, 403, 404, 405, 406, 407, 408, 409, 410, 411, 412, 413, 414, 415, 416, 417, 418,
|
|
20
|
+
421, 422, 423, 424, 425, 426, 428, 429, 431, 451,
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
5: {
|
|
24
|
+
type: "ServerErrorStatusCode",
|
|
25
|
+
codes: [500, 501, 502, 503, 504, 505, 506, 507, 508, 510, 511],
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
/** Hono's `ContentlessStatusCode`: a status that cannot carry a body. */
|
|
29
|
+
export const CONTENTLESS_STATUS_CODES = [101, 204, 205, 304];
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { type EmittedRoute, type RouteSchemaNames } from "typespec-http-zod";
|
|
2
|
+
import type { SecurityRequirement } from "./runtime.js";
|
|
3
|
+
/**
|
|
4
|
+
* Where emitted output imports its runtime from: the copy this package writes beside it.
|
|
5
|
+
*
|
|
6
|
+
* **Always this module.** `runtime-module` used to replace it, and that made an application own a
|
|
7
|
+
* copy of generated logic -- see the `runtime-module-removed` diagnostic.
|
|
8
|
+
*
|
|
9
|
+
* **Deliberately NOT `./runtime.gen.js`, which is what the sibling server emitter uses.** Both
|
|
10
|
+
* emitters run `typespec-http-zod`, and a consumer who wants both surfaces may point them at one
|
|
11
|
+
* output directory. `emitFile` has no duplicate-path detection -- measured, `@typespec/compiler`
|
|
12
|
+
* 1.15.0 `dist/src/core/emitter-utils.js:19-27` -- so two emitters writing one path is a silent
|
|
13
|
+
* last-writer-wins whose winner is decided by the ORDER of `emit:` in `tspconfig.yaml`. Measured
|
|
14
|
+
* end to end: with divergent `seal-object-schemas`, one order produced 19 unsealed objects and the
|
|
15
|
+
* other 11 unsealed plus 8 sealed, exit 0 and no diagnostic either time.
|
|
16
|
+
*
|
|
17
|
+
* Giving this package its own runtime filename means the only file the two can contest is
|
|
18
|
+
* `schemas.gen.ts`, which is byte-identical under identical options.
|
|
19
|
+
*/
|
|
20
|
+
export declare const DEFAULT_RUNTIME_MODULE = "./mcp-runtime.gen.js";
|
|
21
|
+
export interface ToolEntry {
|
|
22
|
+
readonly route: EmittedRoute;
|
|
23
|
+
readonly names: RouteSchemaNames;
|
|
24
|
+
readonly toolName: string;
|
|
25
|
+
readonly requirements: readonly SecurityRequirement[];
|
|
26
|
+
readonly description: string | undefined;
|
|
27
|
+
readonly title: string | undefined;
|
|
28
|
+
}
|
|
29
|
+
export interface RenderedTools {
|
|
30
|
+
readonly text: string;
|
|
31
|
+
/** Operation ids that became tools. */
|
|
32
|
+
readonly emitted: readonly string[];
|
|
33
|
+
}
|
|
34
|
+
export declare function renderTools(entries: readonly ToolEntry[], declaredOperationIds: readonly string[], options: {
|
|
35
|
+
readonly sealed: boolean;
|
|
36
|
+
readonly hint: string | undefined;
|
|
37
|
+
}): RenderedTools;
|