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 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
@@ -1,4 +1,4 @@
1
- import type { EmittedResponse, EmittedRoute, RouteSchemaNames } from "typespec-http-zod";
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
- * A media type MCP has a content block for: `image/*` and `audio/*`, which carry base64 data and
20
- * the media type itself. A wildcard within one is a type the handler names when it answers.
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
- * **Derived by the sibling server emitter's rules, member for member**, because one handler object
28
- * serves both surfaces and `test/agreement/` holds the two result types identical. A status offering
29
- * several media types is several members, because the body differs by media type.
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 status's body under a media type that is not JSON, where that is not the
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 applies.
39
+ * writes it (`"200"`, `"4XX"`, `"default"`) and the media type as declared, and only where one
40
+ * applies.
58
41
  *
59
- * **Per status, not per media type, and that loses nothing.** The library decides `binary` and
60
- * `textual` per response: `binary` only where EVERY media type is non-JSON, `textual` from the body's
61
- * type. So every non-JSON media type a status offers has the same kind, and JSON is read off the
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"];
@@ -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.flatMap((response, index) => {
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 applies.
8
+ * writes it (`"200"`, `"4XX"`, `"default"`) and the media type as declared, and only where one
9
+ * applies.
67
10
  *
68
- * **Per status, not per media type, and that loses nothing.** The library decides `binary` and
69
- * `textual` per response: `binary` only where EVERY media type is non-JSON, `textual` from the body's
70
- * type. So every non-JSON media type a status offers has the same kind, and JSON is read off the
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.responses.flatMap((response) => {
75
- if (response.schema === undefined || response.streamed)
76
- return [];
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 && bodyServingOf(member) === "unvalidated")
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
  });
@@ -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 { httpOperationFor, selectTools } from "./select.js";
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
- * **Keyed on verb and path, never on operation name.** Matching by name never succeeds for an
73
- * operation declared inside an `interface`, which is most of them -- a defect this estate has
74
- * already paid for once, where a whole class of refusal silently pointed at nothing.
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
- const route = emitted.routes.find((candidate) => candidate.operationId === operationId);
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.service, emitted.routes, {
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 = httpOperationFor(emitted.service, route);
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
  /**
@@ -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.
@@ -1,5 +1,5 @@
1
1
  import { getDiscriminator } from "@typespec/compiler";
2
- import { hasContentBlock, unvalidatedResponsesOf } from "./bodies.js";
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
  /**
@@ -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. `test/packaging.test.ts` asserts the
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. `test/packaging.test.ts` asserts that. The
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 `test/runtime/parity.test.ts` is what stops the
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 under a media type that is not JSON, keyed by the arm's status key
79
- * (`"200"`, `"4XX"`, `"default"`), and present only where one applies.
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 status with no entry answers a non-JSON body as the text the handler supplied, unvalidated. A
90
- * body served as JSON needs no entry, because the served media type says so.
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]: "text" | "binary";
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.
@@ -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
- /** A media type whose body is a JSON value. The rule the library and the server emitter apply. */
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
- * `Response` reads it, because both runtimes this output runs on, Node and workerd, provide it.
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 instanceof ReadableStream)
114
- return new Uint8Array(await new Response(body).arrayBuffer());
115
- return undefined;
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 kind = declared.bodies?.[String(arm.status)];
167
- if (mediaType === undefined || isJsonMediaType(mediaType)) {
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**, 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.
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`));