typespec-http-mcp 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +85 -71
- 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/README.md
CHANGED
|
@@ -18,10 +18,13 @@ options:
|
|
|
18
18
|
Install it as a **devDependency**: nothing it emits imports it.
|
|
19
19
|
|
|
20
20
|
```sh
|
|
21
|
-
pnpm add -D typespec-http-mcp
|
|
21
|
+
pnpm add -D typespec-http-mcp @typespec/compiler @typespec/http @typespec/openapi
|
|
22
22
|
pnpm add @modelcontextprotocol/server zod
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
+
Install the three `@typespec` packages at one version, 1.15 or later: left to a package manager,
|
|
26
|
+
`@typespec/openapi` is installed at its newest, which needs the newest compiler.
|
|
27
|
+
|
|
25
28
|
## What it emits, and what you write
|
|
26
29
|
|
|
27
30
|
The generated file is **never edited, only called**. You own the `McpServer`, the transport and the
|
package/dist/src/bodies.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { type EmittedResponse, type EmittedRoute, isJsonMediaType, type ResultMember as LibraryResultMember, type RouteSchemaNames } from "typespec-http-zod";
|
|
2
2
|
/**
|
|
3
3
|
* **What each declared response body IS, decided once, for the renderer, the refusals and the runtime.**
|
|
4
4
|
*
|
|
@@ -11,42 +11,24 @@ import type { EmittedResponse, EmittedRoute, RouteSchemaNames } from "typespec-h
|
|
|
11
11
|
* Pure: it reads the library's types and nothing else, so the renderer can use it without reaching
|
|
12
12
|
* the compiler.
|
|
13
13
|
*/
|
|
14
|
-
/** A media type whose body is a JSON value. The library's own rule, and the runtime's. */
|
|
15
|
-
export declare function isJsonMediaType(type: string): boolean;
|
|
16
|
-
/** A media range such as `image/*`, rather than a type a response can be sent as. */
|
|
17
|
-
export declare function isMediaRange(mediaType: string): boolean;
|
|
18
14
|
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*/
|
|
22
|
-
export declare function hasContentBlock(type: string): boolean;
|
|
23
|
-
/**
|
|
24
|
-
* One thing a handler may return: a declared status, and where that status offers several media
|
|
25
|
-
* types, one of them.
|
|
15
|
+
* One member of a tool's result: the library's member, with the identifier this route's schema for
|
|
16
|
+
* that body is declared under.
|
|
26
17
|
*
|
|
27
|
-
* **
|
|
28
|
-
*
|
|
29
|
-
*
|
|
18
|
+
* **The rules are the library's (`resultMembersOf`), read rather than restated.** This package, the
|
|
19
|
+
* sibling server emitter and the contract types each held their own copy, and three copies of rules
|
|
20
|
+
* that must agree are three that will not.
|
|
30
21
|
*/
|
|
31
|
-
export interface ResultMember {
|
|
32
|
-
readonly response: EmittedResponse;
|
|
33
|
-
/** The identifier the library declared this status's body under. */
|
|
22
|
+
export interface ResultMember extends LibraryResultMember<EmittedResponse> {
|
|
34
23
|
readonly schemaName: string | undefined;
|
|
35
|
-
/** The media type served, or `undefined` where the response has no body. */
|
|
36
|
-
readonly mediaType: string | undefined;
|
|
37
|
-
/** Whether the handler names the media type, because the status offers more than one. */
|
|
38
|
-
readonly choosesMediaType: boolean;
|
|
39
24
|
}
|
|
40
|
-
/** What a handler supplies for a member's body, which decides the body's type. */
|
|
41
|
-
export type BodyServing = "none" | "stream" | "binary" | "json" | "text" | "unvalidated";
|
|
42
|
-
export declare function bodyServingOf(member: ResultMember): BodyServing;
|
|
43
25
|
export declare function membersOf(route: EmittedRoute, names: RouteSchemaNames): ResultMember[];
|
|
26
|
+
export { isJsonMediaType };
|
|
44
27
|
/**
|
|
45
|
-
* How the runtime answers a
|
|
46
|
-
* unvalidated text a handler supplies.
|
|
28
|
+
* How the runtime answers a body where its media type does not say.
|
|
47
29
|
*
|
|
48
30
|
* - `"text"`: a string body, parsed against its schema and answered as a text block;
|
|
49
|
-
* - `"binary"`: raw bytes, answered as an `image` or `audio` block.
|
|
31
|
+
* - `"binary"`: raw bytes, answered as an `image` or `audio` block, or as an embedded resource.
|
|
50
32
|
*
|
|
51
33
|
* A body served as JSON needs no entry, because the served media type says so; nor does unvalidated
|
|
52
34
|
* text, which is what a non-JSON body with no entry is.
|
|
@@ -54,14 +36,14 @@ export declare function membersOf(route: EmittedRoute, names: RouteSchemaNames):
|
|
|
54
36
|
export type BodyKind = "text" | "binary";
|
|
55
37
|
/**
|
|
56
38
|
* The body kinds an operation's runtime needs, keyed by the arm's status key as `schemas.gen.ts`
|
|
57
|
-
* writes it (`"200"`, `"4XX"`, `"default"`), and only where one
|
|
39
|
+
* writes it (`"200"`, `"4XX"`, `"default"`) and the media type as declared, and only where one
|
|
40
|
+
* applies.
|
|
58
41
|
*
|
|
59
|
-
* **
|
|
60
|
-
*
|
|
61
|
-
*
|
|
62
|
-
* served type itself.
|
|
42
|
+
* **Read off the library's members, one per status and media type.** A status may offer one model as
|
|
43
|
+
* XML, the text a handler writes, and as `application/octet-stream`, its bytes; a kind per status
|
|
44
|
+
* could not say both. A file declared `application/json` is bytes, so it has an entry too.
|
|
63
45
|
*/
|
|
64
|
-
export declare function bodyKindsOf(route: EmittedRoute): readonly (readonly [string, BodyKind])[];
|
|
46
|
+
export declare function bodyKindsOf(route: EmittedRoute, names: RouteSchemaNames): readonly (readonly [string, string, BodyKind])[];
|
|
65
47
|
/** Each status whose body under some media type is answered unvalidated, with those media types. */
|
|
66
48
|
export declare function unvalidatedResponsesOf(route: EmittedRoute, names: RouteSchemaNames): readonly {
|
|
67
49
|
readonly status: EmittedResponse["status"];
|
package/dist/src/bodies.js
CHANGED
|
@@ -1,93 +1,28 @@
|
|
|
1
|
-
|
|
2
|
-
* **What each declared response body IS, decided once, for the renderer, the refusals and the runtime.**
|
|
3
|
-
*
|
|
4
|
-
* A handler's result type, the warning about a body nothing validates, the refusal of a body no tool
|
|
5
|
-
* result can carry, and the body kinds the runtime is told about all read the same four facts the
|
|
6
|
-
* library publishes per response: its media types, and whether its body is `binary`, `streamed` or
|
|
7
|
-
* `textual`. Deciding them in one place is what keeps the result type a handler compiles against and
|
|
8
|
-
* the rule the runtime serves it by from disagreeing.
|
|
9
|
-
*
|
|
10
|
-
* Pure: it reads the library's types and nothing else, so the renderer can use it without reaching
|
|
11
|
-
* the compiler.
|
|
12
|
-
*/
|
|
13
|
-
/** A media type whose body is a JSON value. The library's own rule, and the runtime's. */
|
|
14
|
-
export function isJsonMediaType(type) {
|
|
15
|
-
return /^application\/(json|.*\+json)$/.test(type);
|
|
16
|
-
}
|
|
17
|
-
/** A media range such as `image/*`, rather than a type a response can be sent as. */
|
|
18
|
-
export function isMediaRange(mediaType) {
|
|
19
|
-
return mediaType.endsWith("/*");
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* A media type MCP has a content block for: `image/*` and `audio/*`, which carry base64 data and
|
|
23
|
-
* the media type itself. A wildcard within one is a type the handler names when it answers.
|
|
24
|
-
*/
|
|
25
|
-
export function hasContentBlock(type) {
|
|
26
|
-
return /^(image|audio)\/[^/]+$/.test(type);
|
|
27
|
-
}
|
|
28
|
-
export function bodyServingOf(member) {
|
|
29
|
-
const { response, mediaType } = member;
|
|
30
|
-
if (response.schema === undefined || mediaType === undefined)
|
|
31
|
-
return "none";
|
|
32
|
-
if (response.streamed)
|
|
33
|
-
return "stream";
|
|
34
|
-
if (response.binary)
|
|
35
|
-
return "binary";
|
|
36
|
-
if (isJsonMediaType(mediaType))
|
|
37
|
-
return "json";
|
|
38
|
-
/**
|
|
39
|
-
* **A string body under a non-JSON media type IS the text**, so it is validated and answered as is.
|
|
40
|
-
* Any other body under one -- a model as `application/xml` -- has no serialisation this emitter can
|
|
41
|
-
* derive, so the handler supplies the text and it is answered unvalidated.
|
|
42
|
-
*/
|
|
43
|
-
return response.textual ? "text" : "unvalidated";
|
|
44
|
-
}
|
|
1
|
+
import { isJsonMediaType, resultMembersOf, } from "typespec-http-zod";
|
|
45
2
|
export function membersOf(route, names) {
|
|
46
|
-
return route.responses
|
|
47
|
-
const schemaName = names.arms[index]?.schema;
|
|
48
|
-
if (response.contentTypes.length === 0) {
|
|
49
|
-
return [{ response, schemaName, mediaType: undefined, choosesMediaType: false }];
|
|
50
|
-
}
|
|
51
|
-
/**
|
|
52
|
-
* The handler names the media type where the status offers several, and where the document names
|
|
53
|
-
* only a wildcard range, which is not a type a response can be sent as.
|
|
54
|
-
*/
|
|
55
|
-
const choosesMediaType = response.contentTypes.length > 1 || response.contentTypes.some(isMediaRange);
|
|
56
|
-
return response.contentTypes.map((mediaType) => ({
|
|
57
|
-
response,
|
|
58
|
-
schemaName,
|
|
59
|
-
mediaType,
|
|
60
|
-
choosesMediaType,
|
|
61
|
-
}));
|
|
62
|
-
});
|
|
3
|
+
return resultMembersOf(route.responses, (response) => response.schema !== undefined).map((member) => ({ ...member, schemaName: names.arms[member.index]?.schema }));
|
|
63
4
|
}
|
|
5
|
+
export { isJsonMediaType };
|
|
64
6
|
/**
|
|
65
7
|
* The body kinds an operation's runtime needs, keyed by the arm's status key as `schemas.gen.ts`
|
|
66
|
-
* writes it (`"200"`, `"4XX"`, `"default"`), and only where one
|
|
8
|
+
* writes it (`"200"`, `"4XX"`, `"default"`) and the media type as declared, and only where one
|
|
9
|
+
* applies.
|
|
67
10
|
*
|
|
68
|
-
* **
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
* served type itself.
|
|
11
|
+
* **Read off the library's members, one per status and media type.** A status may offer one model as
|
|
12
|
+
* XML, the text a handler writes, and as `application/octet-stream`, its bytes; a kind per status
|
|
13
|
+
* could not say both. A file declared `application/json` is bytes, so it has an entry too.
|
|
72
14
|
*/
|
|
73
|
-
export function bodyKindsOf(route) {
|
|
74
|
-
return route.
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const offersNonJson = response.contentTypes.some((type) => !isJsonMediaType(type));
|
|
78
|
-
if (!offersNonJson)
|
|
79
|
-
return [];
|
|
80
|
-
if (response.binary)
|
|
81
|
-
return [[String(response.status), "binary"]];
|
|
82
|
-
return response.textual ? [[String(response.status), "text"]] : [];
|
|
83
|
-
});
|
|
15
|
+
export function bodyKindsOf(route, names) {
|
|
16
|
+
return membersOf(route, names).flatMap((member) => member.mediaType !== undefined && (member.serving === "text" || member.serving === "binary")
|
|
17
|
+
? [[String(member.response.status), member.mediaType, member.serving]]
|
|
18
|
+
: []);
|
|
84
19
|
}
|
|
85
20
|
/** Each status whose body under some media type is answered unvalidated, with those media types. */
|
|
86
21
|
export function unvalidatedResponsesOf(route, names) {
|
|
87
22
|
const members = membersOf(route, names);
|
|
88
23
|
return route.responses.flatMap((response) => {
|
|
89
24
|
const types = members
|
|
90
|
-
.filter((member) => member.response === response &&
|
|
25
|
+
.filter((member) => member.response === response && member.unvalidated)
|
|
91
26
|
.flatMap((member) => (member.mediaType === undefined ? [] : [member.mediaType]));
|
|
92
27
|
return types.length === 0 ? [] : [{ status: response.status, types }];
|
|
93
28
|
});
|
package/dist/src/emitter.js
CHANGED
|
@@ -8,7 +8,7 @@ import { nameTools } from "./names.js";
|
|
|
8
8
|
import { collisionsFor } from "./coexistence.js";
|
|
9
9
|
import { refusalFor, warningsFor } from "./refusals.js";
|
|
10
10
|
import { renderTools } from "./tools.js";
|
|
11
|
-
import {
|
|
11
|
+
import { selectTools } from "./select.js";
|
|
12
12
|
/**
|
|
13
13
|
* Where emitted output imports its runtime from. Declared where the import is rendered, and
|
|
14
14
|
* re-exported here beside the code that writes the file.
|
|
@@ -67,17 +67,15 @@ function resolveToolOptions(options, serviceName) {
|
|
|
67
67
|
};
|
|
68
68
|
}
|
|
69
69
|
/**
|
|
70
|
-
* Where a diagnostic about an operation should point
|
|
70
|
+
* Where a diagnostic about an operation should point: the operation the library collected the route
|
|
71
|
+
* from, by its id.
|
|
71
72
|
*
|
|
72
|
-
* **
|
|
73
|
-
*
|
|
74
|
-
*
|
|
73
|
+
* **Never by name, and never by verb and path.** A name never matches an operation declared inside
|
|
74
|
+
* an `interface`, whose id carries the interface; verb and path are shared by every member of a
|
|
75
|
+
* `@sharedRoute`, so each member's diagnostic pointed at one of them.
|
|
75
76
|
*/
|
|
76
77
|
function targetFor(emitted, operationId) {
|
|
77
|
-
|
|
78
|
-
if (route === undefined)
|
|
79
|
-
return NoTarget;
|
|
80
|
-
return httpOperationFor(emitted.service, route)?.operation ?? NoTarget;
|
|
78
|
+
return emitted.operations.get(operationId)?.operation ?? NoTarget;
|
|
81
79
|
}
|
|
82
80
|
/**
|
|
83
81
|
* Run the library, then add the MCP surface beside what it wrote.
|
|
@@ -109,7 +107,7 @@ export async function $onEmit(context) {
|
|
|
109
107
|
const resolved = resolveToolOptions(options, serviceName);
|
|
110
108
|
if (!resolved.emitTools)
|
|
111
109
|
continue;
|
|
112
|
-
const selection = selectTools(context.program, emitted.
|
|
110
|
+
const selection = selectTools(context.program, emitted.routes, emitted.operations, {
|
|
113
111
|
toolTags: resolved.toolTags,
|
|
114
112
|
excludeTags: resolved.excludeTags,
|
|
115
113
|
});
|
|
@@ -153,7 +151,7 @@ export async function $onEmit(context) {
|
|
|
153
151
|
if (toolName === undefined)
|
|
154
152
|
continue;
|
|
155
153
|
const names = emitted.schemaNames.get(route.operationId);
|
|
156
|
-
const operation =
|
|
154
|
+
const operation = emitted.operations.get(route.operationId);
|
|
157
155
|
if (names === undefined || operation === undefined)
|
|
158
156
|
continue;
|
|
159
157
|
/** A refusal removes the operation from the surface, so it is counted, never skipped. */
|
package/dist/src/lib.d.ts
CHANGED
|
@@ -84,9 +84,6 @@ type Diagnostics = {
|
|
|
84
84
|
"non-object-body": {
|
|
85
85
|
readonly default: CallableMessage<["operationId", "types"]>;
|
|
86
86
|
};
|
|
87
|
-
"binary-tool-output": {
|
|
88
|
-
readonly default: CallableMessage<["operationId", "types"]>;
|
|
89
|
-
};
|
|
90
87
|
"streamed-tool-output": {
|
|
91
88
|
readonly default: CallableMessage<["operationId", "types"]>;
|
|
92
89
|
};
|
package/dist/src/lib.js
CHANGED
|
@@ -134,22 +134,6 @@ const diagnostics = {
|
|
|
134
134
|
default: paramMessage `'${"operationId"}' takes a binary or multipart request body (${"types"}), which an MCP tool argument cannot carry: tool arguments are JSON. Exclude it with 'exclude-tags', or declare a JSON-bodied operation for the tool surface.`,
|
|
135
135
|
},
|
|
136
136
|
},
|
|
137
|
-
/**
|
|
138
|
-
* A response body an MCP tool result cannot carry, because it is raw bytes with no content block.
|
|
139
|
-
*
|
|
140
|
-
* A tool result is content blocks. MCP has an `image` and an `audio` block, each carrying base64
|
|
141
|
-
* data and its media type, and those are what an `image/*` or `audio/*` body becomes. Any other
|
|
142
|
-
* binary body -- `application/octet-stream`, a PDF -- could only be an embedded resource, and a
|
|
143
|
-
* resource needs a URI the document never states. **Refused rather than approximated**: base64 in
|
|
144
|
-
* a text block would publish an encoding no reader of the document could predict, and would put a
|
|
145
|
-
* whole download through a model's context window.
|
|
146
|
-
*/
|
|
147
|
-
"binary-tool-output": {
|
|
148
|
-
severity: "warning",
|
|
149
|
-
messages: {
|
|
150
|
-
default: paramMessage `'${"operationId"}' answers with a binary body (${"types"}) that no MCP content block carries: a tool result is text, JSON, an 'image/*' block or an 'audio/*' block. Exclude it with 'exclude-tags', or declare an image or audio media type, or a JSON body, for the tool surface.`,
|
|
151
|
-
},
|
|
152
|
-
},
|
|
153
137
|
/**
|
|
154
138
|
* A streamed response, which a tool result cannot be.
|
|
155
139
|
*
|
|
@@ -174,7 +158,7 @@ const diagnostics = {
|
|
|
174
158
|
"unvalidated-response-media-type": {
|
|
175
159
|
severity: "warning",
|
|
176
160
|
messages: {
|
|
177
|
-
default: paramMessage `'${"operationId"}' answers ${"status"} as ${"types"}, which this emitter cannot serialise from the schema. The handler returns that body as text and the tool answers it without validating it. Declare a JSON media type, or a string body, to have it checked.`,
|
|
161
|
+
default: paramMessage `'${"operationId"}' answers ${"status"} as ${"types"}, which this emitter cannot serialise from the schema. The handler returns that body as text, or as bytes under a media type that is not text, and the tool answers it without validating it. Declare a JSON media type, or a string body, to have it checked.`,
|
|
178
162
|
},
|
|
179
163
|
},
|
|
180
164
|
/**
|
package/dist/src/refusals.d.ts
CHANGED
|
@@ -13,9 +13,6 @@ export type Refusal = {
|
|
|
13
13
|
} | {
|
|
14
14
|
readonly kind: "streamed-tool-output";
|
|
15
15
|
readonly types: string;
|
|
16
|
-
} | {
|
|
17
|
-
readonly kind: "binary-tool-output";
|
|
18
|
-
readonly types: string;
|
|
19
16
|
};
|
|
20
17
|
/**
|
|
21
18
|
* Why this operation cannot be a tool, or `undefined` where it can.
|
package/dist/src/refusals.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { getDiscriminator } from "@typespec/compiler";
|
|
2
|
-
import {
|
|
2
|
+
import { unvalidatedResponsesOf } from "./bodies.js";
|
|
3
3
|
/**
|
|
4
4
|
* What this emitter cannot express as a tool, and what it can express but should warn about.
|
|
5
5
|
*
|
|
@@ -74,8 +74,13 @@ export function refusalFor(program, route, operation) {
|
|
|
74
74
|
* A raw body is bytes or a stream; a multipart body is parts, one of which is usually a file.
|
|
75
75
|
* Neither is a JSON value, and encoding one into a string argument would put an upload through a
|
|
76
76
|
* model's context window under a contract the document never stated.
|
|
77
|
+
*
|
|
78
|
+
* **A raw body that is TEXT under no JSON type is not this:** a model declared only as XML is
|
|
79
|
+
* handed to a server as the text that arrived, and what MCP cannot carry about it is the media
|
|
80
|
+
* type, which the next check names. Text under a JSON type - a `bytes` body a signature covers -
|
|
81
|
+
* stays here, as it always has.
|
|
77
82
|
*/
|
|
78
|
-
if (route.rawBodyProperty !== undefined) {
|
|
83
|
+
if (route.rawBodyProperty !== undefined && (route.rawBodyBinary || types.some(isJson))) {
|
|
79
84
|
return {
|
|
80
85
|
kind: "binary-tool-input",
|
|
81
86
|
types: types.length === 0 ? "a raw body" : types.join(", "),
|
|
@@ -114,22 +119,6 @@ export function refusalFor(program, route, operation) {
|
|
|
114
119
|
types: streamed.flatMap((response) => response.contentTypes).join(", ") || "a stream",
|
|
115
120
|
};
|
|
116
121
|
}
|
|
117
|
-
/**
|
|
118
|
-
* **Raw bytes are a tool result only where MCP has a block for them.** An `image/*` or `audio/*`
|
|
119
|
-
* body is answered as an `image` or `audio` content block, which carries the data and its media
|
|
120
|
-
* type. Any other binary body -- `application/octet-stream`, a PDF -- could only be an embedded
|
|
121
|
-
* resource, and a resource needs a URI the document never states. Base64-ing the bytes into a text
|
|
122
|
-
* block would put a download through a model's context window under an encoding nothing published.
|
|
123
|
-
* Refused if ANY declared response offers such a type, because a handler may answer with any of
|
|
124
|
-
* them.
|
|
125
|
-
*/
|
|
126
|
-
const unanswerable = route.responses
|
|
127
|
-
.filter((response) => response.binary)
|
|
128
|
-
.flatMap((response) => response.contentTypes)
|
|
129
|
-
.filter((type) => !hasContentBlock(type));
|
|
130
|
-
if (unanswerable.length > 0) {
|
|
131
|
-
return { kind: "binary-tool-output", types: [...new Set(unanswerable)].join(", ") };
|
|
132
|
-
}
|
|
133
122
|
return undefined;
|
|
134
123
|
}
|
|
135
124
|
/**
|
package/dist/src/runtime.d.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { ZodError, ZodType } from "zod";
|
|
|
6
6
|
* **Kept deliberately tiny, and deliberately free of every TypeSpec import.** The main entry point
|
|
7
7
|
* is an emitter: it pulls in `@typespec/compiler` and exists at build time. This module is what an
|
|
8
8
|
* *application* touches, and making it reach the compiler would put a build-time dependency into a
|
|
9
|
-
* server's runtime graph for the sake of a few declarations.
|
|
9
|
+
* server's runtime graph for the sake of a few declarations. typespec-http-mcp's packaging suite asserts the
|
|
10
10
|
* separation rather than trusting it.
|
|
11
11
|
*
|
|
12
12
|
* **Always emitted, never substituted.** `runtime-module` used to point the generated file at a
|
|
@@ -28,10 +28,10 @@ export type { CallToolResult, McpServer, ServerContext };
|
|
|
28
28
|
*
|
|
29
29
|
* **Declared here rather than imported from `typespec-http-zod`, deliberately.** Emitted output must
|
|
30
30
|
* never import this emitter or the library it runs -- a consumer installs both as devDependencies,
|
|
31
|
-
* so a runtime import of either is a broken install.
|
|
31
|
+
* so a runtime import of either is a broken install. typespec-http-mcp's packaging suite asserts that. The
|
|
32
32
|
* emitted `schemas.gen.ts` declares the same shape locally, so it depends on no runtime at all.
|
|
33
33
|
*
|
|
34
|
-
* The cost is a second declaration of one shape, and
|
|
34
|
+
* The cost is a second declaration of one shape, and typespec-http-mcp's runtime parity suite is what stops the
|
|
35
35
|
* two drifting: it grades this `armFor` against the library's own for every arm shape.
|
|
36
36
|
*
|
|
37
37
|
* **The arms arrive in OpenAPI's precedence order, so the FIRST match is the right one** -- an exact
|
|
@@ -75,22 +75,28 @@ export interface ToolResponses {
|
|
|
75
75
|
*/
|
|
76
76
|
readonly outputSchema: ZodType | undefined;
|
|
77
77
|
/**
|
|
78
|
-
* How a body is answered
|
|
79
|
-
* (`"200"`, `"4XX"`, `"default"`)
|
|
78
|
+
* How a body is answered where its media type does not say, keyed by the arm's status key
|
|
79
|
+
* (`"200"`, `"4XX"`, `"default"`) and then by the media type as the arm declares it, and present
|
|
80
|
+
* only where one applies.
|
|
80
81
|
*
|
|
81
82
|
* **Carried because an arm cannot say it.** The arm names the media types and the schema, and
|
|
82
83
|
* neither tells a string body apart from raw bytes or from a model the handler serialised: an
|
|
83
84
|
* `image/png` body and an `application/xml` one both arrive as a schema and a non-JSON type. The
|
|
84
|
-
* emitter knows, from what the document declares the body to be.
|
|
85
|
+
* emitter knows, from what the document declares the body to be. **By media type as well as by
|
|
86
|
+
* status**, because one status may offer one model as XML, the text the handler writes, and as
|
|
87
|
+
* `application/octet-stream`, its bytes.
|
|
85
88
|
*
|
|
86
89
|
* - `"text"`: a string, parsed against the arm's schema and answered as a `text` block;
|
|
87
|
-
* - `"binary"`: raw bytes, answered as an `image` or `audio` block
|
|
90
|
+
* - `"binary"`: raw bytes, answered as an `image` or `audio` block, or as an embedded resource
|
|
91
|
+
* under any other type, a file declared `application/json` included.
|
|
88
92
|
*
|
|
89
|
-
* A
|
|
90
|
-
*
|
|
93
|
+
* A media type with no entry answers a JSON body as JSON, and any other body as the text the
|
|
94
|
+
* handler supplied, unvalidated.
|
|
91
95
|
*/
|
|
92
96
|
readonly bodies?: {
|
|
93
|
-
readonly [status: string]:
|
|
97
|
+
readonly [status: string]: {
|
|
98
|
+
readonly [mediaType: string]: "text" | "binary";
|
|
99
|
+
};
|
|
94
100
|
};
|
|
95
101
|
}
|
|
96
102
|
/**
|
|
@@ -109,6 +115,10 @@ export declare class ResponseContractError extends Error {
|
|
|
109
115
|
readonly operationId: string;
|
|
110
116
|
readonly status: number;
|
|
111
117
|
readonly issues: ZodError["issues"];
|
|
118
|
+
/**
|
|
119
|
+
* Fields declared and assigned rather than written as parameter properties, which are not
|
|
120
|
+
* erasable syntax: a consumer building with `erasableSyntaxOnly` could not compile this file.
|
|
121
|
+
*/
|
|
112
122
|
constructor(operationId: string, status: number, issues: ZodError["issues"]);
|
|
113
123
|
}
|
|
114
124
|
/**
|
|
@@ -124,6 +134,25 @@ export declare class UndeclaredStatusError extends Error {
|
|
|
124
134
|
readonly result: unknown;
|
|
125
135
|
constructor(operationId: string, result: unknown);
|
|
126
136
|
}
|
|
137
|
+
/**
|
|
138
|
+
* **A stream of bytes, as every platform's `ReadableStream<Uint8Array>` satisfies it.**
|
|
139
|
+
*
|
|
140
|
+
* The same structural type `typespec-http-zod` publishes in `requests.gen.ts` and `typespec-hono`
|
|
141
|
+
* declares in its runtime, so a backend whose result is typed by the framework-free contract can be
|
|
142
|
+
* returned from a handler as it is. Declared here rather than imported because this file is copied
|
|
143
|
+
* into a consumer's Worker, where the emitter packages are not installed; the agreement test holds
|
|
144
|
+
* the declarations identical.
|
|
145
|
+
*/
|
|
146
|
+
export interface ByteStream {
|
|
147
|
+
getReader(): {
|
|
148
|
+
read(): Promise<{
|
|
149
|
+
readonly done: boolean;
|
|
150
|
+
readonly value?: Uint8Array | undefined;
|
|
151
|
+
}>;
|
|
152
|
+
releaseLock(): void;
|
|
153
|
+
cancel?(reason?: unknown): Promise<void>;
|
|
154
|
+
};
|
|
155
|
+
}
|
|
127
156
|
/**
|
|
128
157
|
* The MCP result for what a handler returned: `{ status, body, headers }` for a response the
|
|
129
158
|
* document declares.
|
package/dist/src/runtime.js
CHANGED
|
@@ -25,12 +25,16 @@ export class ResponseContractError extends Error {
|
|
|
25
25
|
operationId;
|
|
26
26
|
status;
|
|
27
27
|
issues;
|
|
28
|
+
/**
|
|
29
|
+
* Fields declared and assigned rather than written as parameter properties, which are not
|
|
30
|
+
* erasable syntax: a consumer building with `erasableSyntaxOnly` could not compile this file.
|
|
31
|
+
*/
|
|
28
32
|
constructor(operationId, status, issues) {
|
|
29
33
|
super(`${operationId} answered ${status} with a body its document does not permit`);
|
|
34
|
+
this.name = "ResponseContractError";
|
|
30
35
|
this.operationId = operationId;
|
|
31
36
|
this.status = status;
|
|
32
37
|
this.issues = issues;
|
|
33
|
-
this.name = "ResponseContractError";
|
|
34
38
|
}
|
|
35
39
|
}
|
|
36
40
|
/**
|
|
@@ -54,9 +58,13 @@ export class UndeclaredStatusError extends Error {
|
|
|
54
58
|
this.name = "UndeclaredStatusError";
|
|
55
59
|
}
|
|
56
60
|
}
|
|
57
|
-
/**
|
|
61
|
+
/**
|
|
62
|
+
* A media type whose body is a JSON value. The rule the library and the server emitter apply, to the
|
|
63
|
+
* media type's essence: `type/subtype`, lowercased, with no parameters (RFC 9110 section 8.3.1), so
|
|
64
|
+
* `application/json; charset=utf-8` is JSON.
|
|
65
|
+
*/
|
|
58
66
|
function isJsonMediaType(type) {
|
|
59
|
-
return /^application\/(json|.*\+json)$/.test(type);
|
|
67
|
+
return /^application\/(json|.*\+json)$/.test((type.split(";")[0] ?? "").trim().toLowerCase());
|
|
60
68
|
}
|
|
61
69
|
/** RFC 9110 `token`, less `*`, which names a range rather than a type. */
|
|
62
70
|
const MEDIA_TOKEN = "[!#$%&'+.^_`|~0-9A-Za-z-]+";
|
|
@@ -102,17 +110,54 @@ function refusedBody(input, message) {
|
|
|
102
110
|
/**
|
|
103
111
|
* The bytes of a binary body, which a handler may hand over in any of the forms the result type admits.
|
|
104
112
|
*
|
|
105
|
-
* A stream is buffered: a tool result is one message, and the block carries the whole of it.
|
|
106
|
-
*
|
|
113
|
+
* A stream is buffered: a tool result is one message, and the block carries the whole of it. It is
|
|
114
|
+
* read through its reader, which is all `ByteStream` promises, so a stream that is not the platform's
|
|
115
|
+
* - a contract value from a backend, a test double - is read the same way. A value that is not a
|
|
116
|
+
* `Uint8Array` is not a byte, and refuses the body rather than being coerced.
|
|
107
117
|
*/
|
|
108
118
|
async function bytesOf(body) {
|
|
109
119
|
if (body instanceof Uint8Array)
|
|
110
120
|
return body;
|
|
111
121
|
if (body instanceof ArrayBuffer)
|
|
112
122
|
return new Uint8Array(body);
|
|
113
|
-
if (body
|
|
114
|
-
return
|
|
115
|
-
|
|
123
|
+
if (!isByteStream(body))
|
|
124
|
+
return undefined;
|
|
125
|
+
const reader = body.getReader();
|
|
126
|
+
const chunks = [];
|
|
127
|
+
let length = 0;
|
|
128
|
+
try {
|
|
129
|
+
for (;;) {
|
|
130
|
+
const { done, value } = await reader.read();
|
|
131
|
+
if (done)
|
|
132
|
+
break;
|
|
133
|
+
// A read may report no chunk and not be done, which `ByteStream` admits; it carries nothing.
|
|
134
|
+
if (value === undefined)
|
|
135
|
+
continue;
|
|
136
|
+
if (!(value instanceof Uint8Array)) {
|
|
137
|
+
// Refused part-way, so the producer is told it will not be read further.
|
|
138
|
+
await reader.cancel?.(value);
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
chunks.push(value);
|
|
142
|
+
length += value.byteLength;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
finally {
|
|
146
|
+
reader.releaseLock();
|
|
147
|
+
}
|
|
148
|
+
const bytes = new Uint8Array(length);
|
|
149
|
+
let offset = 0;
|
|
150
|
+
for (const chunk of chunks) {
|
|
151
|
+
bytes.set(chunk, offset);
|
|
152
|
+
offset += chunk.byteLength;
|
|
153
|
+
}
|
|
154
|
+
return bytes;
|
|
155
|
+
}
|
|
156
|
+
function isByteStream(value) {
|
|
157
|
+
return (typeof value === "object" &&
|
|
158
|
+
value !== null &&
|
|
159
|
+
"getReader" in value &&
|
|
160
|
+
typeof value.getReader === "function");
|
|
116
161
|
}
|
|
117
162
|
/** Base64, in chunks, so a large body does not exceed the argument limit of one call. */
|
|
118
163
|
function base64Of(bytes) {
|
|
@@ -122,6 +167,17 @@ function base64Of(bytes) {
|
|
|
122
167
|
}
|
|
123
168
|
return btoa(binary);
|
|
124
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* **The URI an embedded resource names: this operation's response, which nothing resolves.**
|
|
172
|
+
*
|
|
173
|
+
* The specification requires the `uri` and does not require that `resources/read` answer it: a tool
|
|
174
|
+
* may embed a resource "to provide additional context or data using a suitable URI scheme", and a
|
|
175
|
+
* custom scheme is allowed where it follows RFC 3986. The bytes are one call's answer, so there is
|
|
176
|
+
* nothing to list or read back, and this server declares no `resources` capability, which the
|
|
177
|
+
* specification asks for with a SHOULD. Stainless's MCP servers name such a body
|
|
178
|
+
* `resource://tool-response`; this names the operation too, so two tools' answers are not one URI.
|
|
179
|
+
*/
|
|
180
|
+
const TOOL_RESPONSE_URI = "resource://tool-response/";
|
|
125
181
|
/**
|
|
126
182
|
* The MCP result for what a handler returned: `{ status, body, headers }` for a response the
|
|
127
183
|
* document declares.
|
|
@@ -163,8 +219,36 @@ export async function toolResult(declared, operationId, result) {
|
|
|
163
219
|
* state something the document does not, so `content` stays `[]`: present, an array, and honest.
|
|
164
220
|
*/
|
|
165
221
|
if (arm.schema !== undefined && answer.body !== undefined) {
|
|
166
|
-
const
|
|
167
|
-
|
|
222
|
+
const kinds = declared.bodies?.[String(arm.status)] ?? {};
|
|
223
|
+
const kind = mediaType === undefined
|
|
224
|
+
? undefined
|
|
225
|
+
: Object.entries(kinds).find(([declaredType]) => admits(declaredType, mediaType))?.[1];
|
|
226
|
+
if (kind === "binary" && mediaType !== undefined) {
|
|
227
|
+
/**
|
|
228
|
+
* **Raw bytes are bytes whatever the media type**, as the library places a file, so a file
|
|
229
|
+
* declared `application/json` is not parsed and re-serialised. They are answered as the block
|
|
230
|
+
* MCP defines for their media type, `image` or `audio`, and as an embedded resource under any
|
|
231
|
+
* other: a blob with the media type served.
|
|
232
|
+
*/
|
|
233
|
+
const bytes = await bytesOf(answer.body);
|
|
234
|
+
if (bytes === undefined) {
|
|
235
|
+
throw new ResponseContractError(operationId, status, refusedBody(answer.body, "a binary body is a Uint8Array, an ArrayBuffer or a stream of Uint8Array chunks"));
|
|
236
|
+
}
|
|
237
|
+
const block = /^(image|audio)\//.exec(mediaType.toLowerCase())?.[1];
|
|
238
|
+
toolAnswer.content = [
|
|
239
|
+
block === "image" || block === "audio"
|
|
240
|
+
? { type: block, data: base64Of(bytes), mimeType: mediaType }
|
|
241
|
+
: {
|
|
242
|
+
type: "resource",
|
|
243
|
+
resource: {
|
|
244
|
+
uri: `${TOOL_RESPONSE_URI}${encodeURIComponent(operationId)}`,
|
|
245
|
+
mimeType: mediaType,
|
|
246
|
+
blob: base64Of(bytes),
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
];
|
|
250
|
+
}
|
|
251
|
+
else if (mediaType === undefined || isJsonMediaType(mediaType)) {
|
|
168
252
|
/**
|
|
169
253
|
* **Validated wherever the body is served as JSON**, which is also the only form a tool result
|
|
170
254
|
* can carry structurally. An arm naming no media type is a hand-written arm, and a tool
|
|
@@ -198,27 +282,12 @@ export async function toolResult(declared, operationId, result) {
|
|
|
198
282
|
}
|
|
199
283
|
toolAnswer.content = [{ type: "text", text: String(parsed.data) }];
|
|
200
284
|
}
|
|
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
285
|
else {
|
|
218
286
|
/**
|
|
219
|
-
* **A model under a media type that is not JSON is text the handler serialised**,
|
|
220
|
-
* schema does not describe, so it is answered as it is
|
|
221
|
-
*
|
|
287
|
+
* **A model under a textual media type that is not JSON is text the handler serialised**,
|
|
288
|
+
* which the schema does not describe, so it is answered as it is; under a type that is not
|
|
289
|
+
* text it is bytes, and has a `binary` entry. `unvalidated-response-media-type` said so at
|
|
290
|
+
* compile time. Anything but a string is not that text.
|
|
222
291
|
*/
|
|
223
292
|
if (typeof answer.body !== "string") {
|
|
224
293
|
throw new ResponseContractError(operationId, status, refusedBody(answer.body, `a body answered as ${mediaType} is the text the handler supplies`));
|