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/src/runtime.ts
CHANGED
|
@@ -7,7 +7,7 @@ import type { ZodError, ZodType } from "zod";
|
|
|
7
7
|
* **Kept deliberately tiny, and deliberately free of every TypeSpec import.** The main entry point
|
|
8
8
|
* is an emitter: it pulls in `@typespec/compiler` and exists at build time. This module is what an
|
|
9
9
|
* *application* touches, and making it reach the compiler would put a build-time dependency into a
|
|
10
|
-
* server's runtime graph for the sake of a few declarations.
|
|
10
|
+
* server's runtime graph for the sake of a few declarations. typespec-http-mcp's packaging suite asserts the
|
|
11
11
|
* separation rather than trusting it.
|
|
12
12
|
*
|
|
13
13
|
* **Always emitted, never substituted.** `runtime-module` used to point the generated file at a
|
|
@@ -31,10 +31,10 @@ export type { CallToolResult, McpServer, ServerContext };
|
|
|
31
31
|
*
|
|
32
32
|
* **Declared here rather than imported from `typespec-http-zod`, deliberately.** Emitted output must
|
|
33
33
|
* never import this emitter or the library it runs -- a consumer installs both as devDependencies,
|
|
34
|
-
* so a runtime import of either is a broken install.
|
|
34
|
+
* so a runtime import of either is a broken install. typespec-http-mcp's packaging suite asserts that. The
|
|
35
35
|
* emitted `schemas.gen.ts` declares the same shape locally, so it depends on no runtime at all.
|
|
36
36
|
*
|
|
37
|
-
* The cost is a second declaration of one shape, and
|
|
37
|
+
* The cost is a second declaration of one shape, and typespec-http-mcp's runtime parity suite is what stops the
|
|
38
38
|
* two drifting: it grades this `armFor` against the library's own for every arm shape.
|
|
39
39
|
*
|
|
40
40
|
* **The arms arrive in OpenAPI's precedence order, so the FIRST match is the right one** -- an exact
|
|
@@ -85,21 +85,27 @@ export interface ToolResponses {
|
|
|
85
85
|
*/
|
|
86
86
|
readonly outputSchema: ZodType | undefined;
|
|
87
87
|
/**
|
|
88
|
-
* How a body is answered
|
|
89
|
-
* (`"200"`, `"4XX"`, `"default"`)
|
|
88
|
+
* How a body is answered where its media type does not say, keyed by the arm's status key
|
|
89
|
+
* (`"200"`, `"4XX"`, `"default"`) and then by the media type as the arm declares it, and present
|
|
90
|
+
* only where one applies.
|
|
90
91
|
*
|
|
91
92
|
* **Carried because an arm cannot say it.** The arm names the media types and the schema, and
|
|
92
93
|
* neither tells a string body apart from raw bytes or from a model the handler serialised: an
|
|
93
94
|
* `image/png` body and an `application/xml` one both arrive as a schema and a non-JSON type. The
|
|
94
|
-
* emitter knows, from what the document declares the body to be.
|
|
95
|
+
* emitter knows, from what the document declares the body to be. **By media type as well as by
|
|
96
|
+
* status**, because one status may offer one model as XML, the text the handler writes, and as
|
|
97
|
+
* `application/octet-stream`, its bytes.
|
|
95
98
|
*
|
|
96
99
|
* - `"text"`: a string, parsed against the arm's schema and answered as a `text` block;
|
|
97
|
-
* - `"binary"`: raw bytes, answered as an `image` or `audio` block
|
|
100
|
+
* - `"binary"`: raw bytes, answered as an `image` or `audio` block, or as an embedded resource
|
|
101
|
+
* under any other type, a file declared `application/json` included.
|
|
98
102
|
*
|
|
99
|
-
* A
|
|
100
|
-
*
|
|
103
|
+
* A media type with no entry answers a JSON body as JSON, and any other body as the text the
|
|
104
|
+
* handler supplied, unvalidated.
|
|
101
105
|
*/
|
|
102
|
-
readonly bodies?: {
|
|
106
|
+
readonly bodies?: {
|
|
107
|
+
readonly [status: string]: { readonly [mediaType: string]: "text" | "binary" };
|
|
108
|
+
};
|
|
103
109
|
}
|
|
104
110
|
|
|
105
111
|
/**
|
|
@@ -115,13 +121,20 @@ export interface ToolResponses {
|
|
|
115
121
|
* log.
|
|
116
122
|
*/
|
|
117
123
|
export class ResponseContractError extends Error {
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
124
|
+
readonly operationId: string;
|
|
125
|
+
readonly status: number;
|
|
126
|
+
readonly issues: ZodError["issues"];
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Fields declared and assigned rather than written as parameter properties, which are not
|
|
130
|
+
* erasable syntax: a consumer building with `erasableSyntaxOnly` could not compile this file.
|
|
131
|
+
*/
|
|
132
|
+
constructor(operationId: string, status: number, issues: ZodError["issues"]) {
|
|
123
133
|
super(`${operationId} answered ${status} with a body its document does not permit`);
|
|
124
134
|
this.name = "ResponseContractError";
|
|
135
|
+
this.operationId = operationId;
|
|
136
|
+
this.status = status;
|
|
137
|
+
this.issues = issues;
|
|
125
138
|
}
|
|
126
139
|
}
|
|
127
140
|
|
|
@@ -134,22 +147,28 @@ export class ResponseContractError extends Error {
|
|
|
134
147
|
* answered, because answering would publish a status the contract does not state.
|
|
135
148
|
*/
|
|
136
149
|
export class UndeclaredStatusError extends Error {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
) {
|
|
150
|
+
readonly operationId: string;
|
|
151
|
+
readonly result: unknown;
|
|
152
|
+
|
|
153
|
+
constructor(operationId: string, result: unknown) {
|
|
141
154
|
const status =
|
|
142
155
|
typeof result === "object" && result !== null && "status" in result
|
|
143
156
|
? String(result.status)
|
|
144
157
|
: "no status";
|
|
145
158
|
super(`${operationId} answered ${status}, which its document does not declare`);
|
|
159
|
+
this.operationId = operationId;
|
|
160
|
+
this.result = result;
|
|
146
161
|
this.name = "UndeclaredStatusError";
|
|
147
162
|
}
|
|
148
163
|
}
|
|
149
164
|
|
|
150
|
-
/**
|
|
165
|
+
/**
|
|
166
|
+
* A media type whose body is a JSON value. The rule the library and the server emitter apply, to the
|
|
167
|
+
* media type's essence: `type/subtype`, lowercased, with no parameters (RFC 9110 section 8.3.1), so
|
|
168
|
+
* `application/json; charset=utf-8` is JSON.
|
|
169
|
+
*/
|
|
151
170
|
function isJsonMediaType(type: string): boolean {
|
|
152
|
-
return /^application\/(json|.*\+json)$/.test(type);
|
|
171
|
+
return /^application\/(json|.*\+json)$/.test((type.split(";")[0] ?? "").trim().toLowerCase());
|
|
153
172
|
}
|
|
154
173
|
|
|
155
174
|
/** RFC 9110 `token`, less `*`, which names a range rather than a type. */
|
|
@@ -198,17 +217,71 @@ function refusedBody(input: unknown, message: string): ZodError["issues"] {
|
|
|
198
217
|
return [{ code: "custom", path: [], message, input }];
|
|
199
218
|
}
|
|
200
219
|
|
|
220
|
+
/**
|
|
221
|
+
* **A stream of bytes, as every platform's `ReadableStream<Uint8Array>` satisfies it.**
|
|
222
|
+
*
|
|
223
|
+
* The same structural type `typespec-http-zod` publishes in `requests.gen.ts` and `typespec-hono`
|
|
224
|
+
* declares in its runtime, so a backend whose result is typed by the framework-free contract can be
|
|
225
|
+
* returned from a handler as it is. Declared here rather than imported because this file is copied
|
|
226
|
+
* into a consumer's Worker, where the emitter packages are not installed; the agreement test holds
|
|
227
|
+
* the declarations identical.
|
|
228
|
+
*/
|
|
229
|
+
export interface ByteStream {
|
|
230
|
+
getReader(): {
|
|
231
|
+
read(): Promise<{ readonly done: boolean; readonly value?: Uint8Array | undefined }>;
|
|
232
|
+
releaseLock(): void;
|
|
233
|
+
cancel?(reason?: unknown): Promise<void>;
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
201
237
|
/**
|
|
202
238
|
* The bytes of a binary body, which a handler may hand over in any of the forms the result type admits.
|
|
203
239
|
*
|
|
204
|
-
* A stream is buffered: a tool result is one message, and the block carries the whole of it.
|
|
205
|
-
*
|
|
240
|
+
* A stream is buffered: a tool result is one message, and the block carries the whole of it. It is
|
|
241
|
+
* read through its reader, which is all `ByteStream` promises, so a stream that is not the platform's
|
|
242
|
+
* - a contract value from a backend, a test double - is read the same way. A value that is not a
|
|
243
|
+
* `Uint8Array` is not a byte, and refuses the body rather than being coerced.
|
|
206
244
|
*/
|
|
207
245
|
async function bytesOf(body: unknown): Promise<Uint8Array | undefined> {
|
|
208
246
|
if (body instanceof Uint8Array) return body;
|
|
209
247
|
if (body instanceof ArrayBuffer) return new Uint8Array(body);
|
|
210
|
-
if (body
|
|
211
|
-
|
|
248
|
+
if (!isByteStream(body)) return undefined;
|
|
249
|
+
const reader = body.getReader();
|
|
250
|
+
const chunks: Uint8Array[] = [];
|
|
251
|
+
let length = 0;
|
|
252
|
+
try {
|
|
253
|
+
for (;;) {
|
|
254
|
+
const { done, value } = await reader.read();
|
|
255
|
+
if (done) break;
|
|
256
|
+
// A read may report no chunk and not be done, which `ByteStream` admits; it carries nothing.
|
|
257
|
+
if (value === undefined) continue;
|
|
258
|
+
if (!(value instanceof Uint8Array)) {
|
|
259
|
+
// Refused part-way, so the producer is told it will not be read further.
|
|
260
|
+
await reader.cancel?.(value);
|
|
261
|
+
return undefined;
|
|
262
|
+
}
|
|
263
|
+
chunks.push(value);
|
|
264
|
+
length += value.byteLength;
|
|
265
|
+
}
|
|
266
|
+
} finally {
|
|
267
|
+
reader.releaseLock();
|
|
268
|
+
}
|
|
269
|
+
const bytes = new Uint8Array(length);
|
|
270
|
+
let offset = 0;
|
|
271
|
+
for (const chunk of chunks) {
|
|
272
|
+
bytes.set(chunk, offset);
|
|
273
|
+
offset += chunk.byteLength;
|
|
274
|
+
}
|
|
275
|
+
return bytes;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function isByteStream(value: unknown): value is ByteStream {
|
|
279
|
+
return (
|
|
280
|
+
typeof value === "object" &&
|
|
281
|
+
value !== null &&
|
|
282
|
+
"getReader" in value &&
|
|
283
|
+
typeof value.getReader === "function"
|
|
284
|
+
);
|
|
212
285
|
}
|
|
213
286
|
|
|
214
287
|
/** Base64, in chunks, so a large body does not exceed the argument limit of one call. */
|
|
@@ -220,10 +293,26 @@ function base64Of(bytes: Uint8Array): string {
|
|
|
220
293
|
return btoa(binary);
|
|
221
294
|
}
|
|
222
295
|
|
|
296
|
+
/**
|
|
297
|
+
* **The URI an embedded resource names: this operation's response, which nothing resolves.**
|
|
298
|
+
*
|
|
299
|
+
* The specification requires the `uri` and does not require that `resources/read` answer it: a tool
|
|
300
|
+
* may embed a resource "to provide additional context or data using a suitable URI scheme", and a
|
|
301
|
+
* custom scheme is allowed where it follows RFC 3986. The bytes are one call's answer, so there is
|
|
302
|
+
* nothing to list or read back, and this server declares no `resources` capability, which the
|
|
303
|
+
* specification asks for with a SHOULD. Stainless's MCP servers name such a body
|
|
304
|
+
* `resource://tool-response`; this names the operation too, so two tools' answers are not one URI.
|
|
305
|
+
*/
|
|
306
|
+
const TOOL_RESPONSE_URI = "resource://tool-response/";
|
|
307
|
+
|
|
223
308
|
/** One block of a tool result, as the MCP specification defines the ones this runtime answers with. */
|
|
224
309
|
type ContentBlock =
|
|
225
310
|
| { readonly type: "text"; readonly text: string }
|
|
226
|
-
| { readonly type: "image" | "audio"; readonly data: string; readonly mimeType: string }
|
|
311
|
+
| { readonly type: "image" | "audio"; readonly data: string; readonly mimeType: string }
|
|
312
|
+
| {
|
|
313
|
+
readonly type: "resource";
|
|
314
|
+
readonly resource: { readonly uri: string; readonly mimeType: string; readonly blob: string };
|
|
315
|
+
};
|
|
227
316
|
|
|
228
317
|
/**
|
|
229
318
|
* The MCP result for what a handler returned: `{ status, body, headers }` for a response the
|
|
@@ -284,8 +373,43 @@ export async function toolResult(
|
|
|
284
373
|
* state something the document does not, so `content` stays `[]`: present, an array, and honest.
|
|
285
374
|
*/
|
|
286
375
|
if (arm.schema !== undefined && answer.body !== undefined) {
|
|
287
|
-
const
|
|
288
|
-
|
|
376
|
+
const kinds = declared.bodies?.[String(arm.status)] ?? {};
|
|
377
|
+
const kind =
|
|
378
|
+
mediaType === undefined
|
|
379
|
+
? undefined
|
|
380
|
+
: Object.entries(kinds).find(([declaredType]) => admits(declaredType, mediaType))?.[1];
|
|
381
|
+
if (kind === "binary" && mediaType !== undefined) {
|
|
382
|
+
/**
|
|
383
|
+
* **Raw bytes are bytes whatever the media type**, as the library places a file, so a file
|
|
384
|
+
* declared `application/json` is not parsed and re-serialised. They are answered as the block
|
|
385
|
+
* MCP defines for their media type, `image` or `audio`, and as an embedded resource under any
|
|
386
|
+
* other: a blob with the media type served.
|
|
387
|
+
*/
|
|
388
|
+
const bytes = await bytesOf(answer.body);
|
|
389
|
+
if (bytes === undefined) {
|
|
390
|
+
throw new ResponseContractError(
|
|
391
|
+
operationId,
|
|
392
|
+
status,
|
|
393
|
+
refusedBody(
|
|
394
|
+
answer.body,
|
|
395
|
+
"a binary body is a Uint8Array, an ArrayBuffer or a stream of Uint8Array chunks",
|
|
396
|
+
),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
const block = /^(image|audio)\//.exec(mediaType.toLowerCase())?.[1];
|
|
400
|
+
toolAnswer.content = [
|
|
401
|
+
block === "image" || block === "audio"
|
|
402
|
+
? { type: block, data: base64Of(bytes), mimeType: mediaType }
|
|
403
|
+
: {
|
|
404
|
+
type: "resource",
|
|
405
|
+
resource: {
|
|
406
|
+
uri: `${TOOL_RESPONSE_URI}${encodeURIComponent(operationId)}`,
|
|
407
|
+
mimeType: mediaType,
|
|
408
|
+
blob: base64Of(bytes),
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
];
|
|
412
|
+
} else if (mediaType === undefined || isJsonMediaType(mediaType)) {
|
|
289
413
|
/**
|
|
290
414
|
* **Validated wherever the body is served as JSON**, which is also the only form a tool result
|
|
291
415
|
* can carry structurally. An arm naming no media type is a hand-written arm, and a tool
|
|
@@ -317,37 +441,12 @@ export async function toolResult(
|
|
|
317
441
|
throw new ResponseContractError(operationId, status, parsed.error.issues);
|
|
318
442
|
}
|
|
319
443
|
toolAnswer.content = [{ type: "text", text: String(parsed.data) }];
|
|
320
|
-
} else if (kind === "binary") {
|
|
321
|
-
/**
|
|
322
|
-
* **Raw bytes are answered as the block MCP defines for their media type**, carrying the data
|
|
323
|
-
* as base64 and the media type served. The emitter refuses every other binary media type, so
|
|
324
|
-
* one reaching here was declared by hand.
|
|
325
|
-
*/
|
|
326
|
-
const block = /^(image|audio)\//.exec(mediaType)?.[1];
|
|
327
|
-
if (block !== "image" && block !== "audio") {
|
|
328
|
-
throw new ResponseContractError(
|
|
329
|
-
operationId,
|
|
330
|
-
status,
|
|
331
|
-
refusedBody(answer.body, `no MCP content block carries a binary body as ${mediaType}`),
|
|
332
|
-
);
|
|
333
|
-
}
|
|
334
|
-
const bytes = await bytesOf(answer.body);
|
|
335
|
-
if (bytes === undefined) {
|
|
336
|
-
throw new ResponseContractError(
|
|
337
|
-
operationId,
|
|
338
|
-
status,
|
|
339
|
-
refusedBody(
|
|
340
|
-
answer.body,
|
|
341
|
-
"a binary body is a Uint8Array, an ArrayBuffer or a ReadableStream",
|
|
342
|
-
),
|
|
343
|
-
);
|
|
344
|
-
}
|
|
345
|
-
toolAnswer.content = [{ type: block, data: base64Of(bytes), mimeType: mediaType }];
|
|
346
444
|
} else {
|
|
347
445
|
/**
|
|
348
|
-
* **A model under a media type that is not JSON is text the handler serialised**,
|
|
349
|
-
* schema does not describe, so it is answered as it is
|
|
350
|
-
*
|
|
446
|
+
* **A model under a textual media type that is not JSON is text the handler serialised**,
|
|
447
|
+
* which the schema does not describe, so it is answered as it is; under a type that is not
|
|
448
|
+
* text it is bytes, and has a `binary` entry. `unvalidated-response-media-type` said so at
|
|
449
|
+
* compile time. Anything but a string is not that text.
|
|
351
450
|
*/
|
|
352
451
|
if (typeof answer.body !== "string") {
|
|
353
452
|
throw new ResponseContractError(
|
package/dist/src/statuses.d.ts
DELETED
|
@@ -1,35 +0,0 @@
|
|
|
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];
|
package/dist/src/statuses.js
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
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];
|