typespec-http-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +141 -0
- package/dist/src/annotations.d.ts +33 -0
- package/dist/src/annotations.js +41 -0
- package/dist/src/bodies.d.ts +69 -0
- package/dist/src/bodies.js +94 -0
- package/dist/src/coexistence.d.ts +13 -0
- package/dist/src/coexistence.js +91 -0
- package/dist/src/emitter.d.ts +15 -0
- package/dist/src/emitter.js +237 -0
- package/dist/src/index.d.ts +10 -0
- package/dist/src/index.js +10 -0
- package/dist/src/lib.d.ts +119 -0
- package/dist/src/lib.js +278 -0
- package/dist/src/names.d.ts +34 -0
- package/dist/src/names.js +55 -0
- package/dist/src/refusals.d.ts +52 -0
- package/dist/src/refusals.js +167 -0
- package/dist/src/runtime.d.ts +191 -0
- package/dist/src/runtime.js +280 -0
- package/dist/src/select.d.ts +49 -0
- package/dist/src/select.js +59 -0
- package/dist/src/statuses.d.ts +35 -0
- package/dist/src/statuses.js +29 -0
- package/dist/src/tools.d.ts +37 -0
- package/dist/src/tools.js +545 -0
- package/dist/src/tsp-index.d.ts +14 -0
- package/dist/src/tsp-index.js +14 -0
- package/lib/main.tsp +3 -0
- package/package.json +95 -0
- package/src/runtime.ts +475 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bison Digital
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# typespec-http-mcp
|
|
2
|
+
|
|
3
|
+
A TypeSpec emitter that generates an **MCP tool surface** from an HTTP service definition, and the
|
|
4
|
+
Zod validators it publishes.
|
|
5
|
+
|
|
6
|
+
The generated code dispatches into **the same handler object** a generated HTTP server calls, so one
|
|
7
|
+
implementation serves both surfaces and neither is written twice.
|
|
8
|
+
|
|
9
|
+
```yaml
|
|
10
|
+
# tspconfig.yaml
|
|
11
|
+
emit:
|
|
12
|
+
- typespec-http-mcp
|
|
13
|
+
options:
|
|
14
|
+
typespec-http-mcp:
|
|
15
|
+
emitter-output-dir: "{project-root}/src/generated"
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
Install it as a **devDependency**: nothing it emits imports it.
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pnpm add -D typespec-http-mcp
|
|
22
|
+
pnpm add @modelcontextprotocol/server zod
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## What it emits, and what you write
|
|
26
|
+
|
|
27
|
+
The generated file is **never edited, only called**. You own the `McpServer`, the transport and the
|
|
28
|
+
authentication in front of it:
|
|
29
|
+
|
|
30
|
+
```ts
|
|
31
|
+
import { McpServer, createMcpHandler, type AuthInfo } from "@modelcontextprotocol/server";
|
|
32
|
+
import { registerTools } from "./generated/mcp.gen.js";
|
|
33
|
+
import type { ToolDeps } from "./generated/mcp-runtime.gen.js";
|
|
34
|
+
import { handlers } from "./handlers.js"; // the same object your HTTP server gets
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The factory runs PER REQUEST and receives that request's verified identity, so `deps` is built
|
|
38
|
+
* here rather than once at module scope. Built once, `permits` would close over no caller and every
|
|
39
|
+
* client would see an identical tool list.
|
|
40
|
+
*/
|
|
41
|
+
const handler = createMcpHandler((context) => {
|
|
42
|
+
const scopes = context.authInfo?.scopes ?? [];
|
|
43
|
+
|
|
44
|
+
const deps: ToolDeps<AuthInfo> = {
|
|
45
|
+
permits: (requirements) =>
|
|
46
|
+
requirements.length === 0 ||
|
|
47
|
+
requirements.some((requirement) =>
|
|
48
|
+
Object.entries(requirement).every(([, needed]) =>
|
|
49
|
+
needed.every((scope) => scopes.includes(scope)),
|
|
50
|
+
),
|
|
51
|
+
),
|
|
52
|
+
context: (serverContext) => serverContext.http?.authInfo ?? null,
|
|
53
|
+
noContext: () => ({ content: [{ type: "text", text: "Not authenticated." }], isError: true }),
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const server = new McpServer({ name: "widgets", version: "1.0.0" });
|
|
57
|
+
registerTools(server, () => handlers, deps);
|
|
58
|
+
return server;
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export default {
|
|
62
|
+
fetch(request: Request): Promise<Response> {
|
|
63
|
+
return handler.fetch(request);
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
`createMcpHandler` returns an object with a `fetch` method -- **not a callable**. Calling the
|
|
69
|
+
returned value directly is the shape of Cloudflare's `agents` wrapper, not of this SDK, and gives
|
|
70
|
+
`TypeError: handler is not a function`.
|
|
71
|
+
|
|
72
|
+
## What a handler returns
|
|
73
|
+
|
|
74
|
+
A handler returns `{ status, body, headers }` for any response the document declares, failures
|
|
75
|
+
included:
|
|
76
|
+
|
|
77
|
+
```ts
|
|
78
|
+
import type { AuthInfo } from "@modelcontextprotocol/server";
|
|
79
|
+
import type { Tools } from "./generated/mcp.gen.js";
|
|
80
|
+
|
|
81
|
+
export const handlers = {
|
|
82
|
+
WidgetTools_read: (caller, input) => {
|
|
83
|
+
const widget = widgets.get(input.widgetId);
|
|
84
|
+
return widget === undefined
|
|
85
|
+
? { status: 404, body: { resource: input.widgetId } }
|
|
86
|
+
: { status: 200, body: widget };
|
|
87
|
+
},
|
|
88
|
+
} satisfies Tools<AuthInfo>;
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
`caller` has the type `deps.context` returns, which `registerTools` infers; nothing declares it.
|
|
92
|
+
`satisfies` keeps each `status` a literal, which is what selects the response
|
|
93
|
+
it belongs to. **It is not an annotation**: annotating the object widens it, and the check that
|
|
94
|
+
refuses a handler for an operation the spec no longer declares stops working. Where the same object
|
|
95
|
+
also serves a generated HTTP server, write `satisfies Operations<Caller>` from that server's file
|
|
96
|
+
instead, because a tool set may be a subset of the operations.
|
|
97
|
+
|
|
98
|
+
What the model receives follows from the document:
|
|
99
|
+
|
|
100
|
+
- a 4xx or 5xx is a tool-execution error, `isError: true`, whose text is that status's own body;
|
|
101
|
+
- a JSON body is parsed against the schema its status publishes, and the parsed value is what is
|
|
102
|
+
sent. A body that does not match throws `ResponseContractError`, and a status the operation does
|
|
103
|
+
not declare throws `UndeclaredStatusError`;
|
|
104
|
+
- `structuredContent` carries the body when its schema is the tool's `outputSchema`;
|
|
105
|
+
- response headers are carried in `_meta["typespec-http-mcp/response-headers"]`, keyed by wire name.
|
|
106
|
+
|
|
107
|
+
## What is derived, and from what
|
|
108
|
+
|
|
109
|
+
Nothing is invented. Every field of a tool comes from something the document already states, or from
|
|
110
|
+
a published specification:
|
|
111
|
+
|
|
112
|
+
| tool field | derived from |
|
|
113
|
+
| ---------------------------- | ---------------------------------------------------- |
|
|
114
|
+
| `name` | the operation id the document publishes |
|
|
115
|
+
| `title` | `@summary` |
|
|
116
|
+
| `description` | `@doc` |
|
|
117
|
+
| `inputSchema` | the emitted Zod schema, **input** projection |
|
|
118
|
+
| `outputSchema` | the body every 2xx and 3xx status shares |
|
|
119
|
+
| `annotations.readOnlyHint` | the method's safety, per IANA's HTTP Method Registry |
|
|
120
|
+
| `annotations.idempotentHint` | the method's idempotence, same source |
|
|
121
|
+
| which operations are tools | `@tag`, which `@typespec/openapi3` publishes |
|
|
122
|
+
|
|
123
|
+
There are **no decorators**. A decorator would let a spec state something the OpenAPI document
|
|
124
|
+
cannot publish, so the emitted surface would enforce a rule no reader of the contract can see.
|
|
125
|
+
|
|
126
|
+
## Documentation
|
|
127
|
+
|
|
128
|
+
- [`docs/reference.md`](docs/reference.md) -- every option and every diagnostic.
|
|
129
|
+
- [`docs/architecture.md`](docs/architecture.md) -- what each module owns, and the boundaries that are enforced.
|
|
130
|
+
- [`docs/oracles.md`](docs/oracles.md) -- what each emitted artefact is compared against.
|
|
131
|
+
- [`docs/guides.md`](docs/guides.md) -- wiring it up, serving both surfaces, and testing one.
|
|
132
|
+
- [`docs/releasing.md`](docs/releasing.md) -- the local-registry rehearsal and the proof this package passed.
|
|
133
|
+
- [`docs/measurements.md`](docs/measurements.md) -- the probes the design rests on.
|
|
134
|
+
|
|
135
|
+
## Working record
|
|
136
|
+
|
|
137
|
+
[`HANDOVER.md`](HANDOVER.md) -- state, the traps, and what is open.
|
|
138
|
+
|
|
139
|
+
## Licence
|
|
140
|
+
|
|
141
|
+
MIT.
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP tool hints, derived from the method's own semantics.
|
|
3
|
+
*
|
|
4
|
+
* **Every value here comes from RFC 9110, and the oracle that grades it is IANA's HTTP Method
|
|
5
|
+
* Registry** -- a table a third party publishes, not the one below. An arm that recomputed
|
|
6
|
+
* `verb in {GET, HEAD}` from this same map would agree with any mistake it contains.
|
|
7
|
+
*
|
|
8
|
+
* | hint | source |
|
|
9
|
+
* | --- | --- |
|
|
10
|
+
* | `readOnlyHint` | RFC 9110 s9.2.1, safe methods |
|
|
11
|
+
* | `idempotentHint` | RFC 9110 s9.2.2, idempotent methods |
|
|
12
|
+
* | `destructiveHint` | no external source; see below |
|
|
13
|
+
* | `openWorldHint` | not derivable, never written |
|
|
14
|
+
*/
|
|
15
|
+
export interface ToolAnnotations {
|
|
16
|
+
readonly readOnlyHint?: boolean;
|
|
17
|
+
readonly idempotentHint?: boolean;
|
|
18
|
+
readonly destructiveHint?: boolean;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* What this emitter states about a method, and what it deliberately leaves unsaid.
|
|
22
|
+
*
|
|
23
|
+
* - `readOnlyHint: true` for a safe method. **`false` is never written**: it is MCP's default, so
|
|
24
|
+
* writing it says nothing while implying the emitter knows something it does not.
|
|
25
|
+
* - `idempotentHint` is written in BOTH directions, because the document's method makes both
|
|
26
|
+
* derivable. POST and PATCH are explicitly not idempotent, and saying so is a fact rather than a
|
|
27
|
+
* guess.
|
|
28
|
+
* - `destructiveHint: true` for DELETE only. **Omitted for POST and PATCH**, where MCP's default of
|
|
29
|
+
* `true` stands: the document gives no basis to contradict it, and an emitter must not invent one.
|
|
30
|
+
* - `openWorldHint` is never written. Whether an operation reaches beyond this service is not
|
|
31
|
+
* something any OpenAPI keyword states.
|
|
32
|
+
*/
|
|
33
|
+
export declare function annotationsFor(verb: string): ToolAnnotations;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The MCP tool hints, derived from the method's own semantics.
|
|
3
|
+
*
|
|
4
|
+
* **Every value here comes from RFC 9110, and the oracle that grades it is IANA's HTTP Method
|
|
5
|
+
* Registry** -- a table a third party publishes, not the one below. An arm that recomputed
|
|
6
|
+
* `verb in {GET, HEAD}` from this same map would agree with any mistake it contains.
|
|
7
|
+
*
|
|
8
|
+
* | hint | source |
|
|
9
|
+
* | --- | --- |
|
|
10
|
+
* | `readOnlyHint` | RFC 9110 s9.2.1, safe methods |
|
|
11
|
+
* | `idempotentHint` | RFC 9110 s9.2.2, idempotent methods |
|
|
12
|
+
* | `destructiveHint` | no external source; see below |
|
|
13
|
+
* | `openWorldHint` | not derivable, never written |
|
|
14
|
+
*/
|
|
15
|
+
/** Safe methods: RFC 9110 s9.2.1. A safe method is also idempotent, s9.2.2. */
|
|
16
|
+
const SAFE = new Set(["get", "head"]);
|
|
17
|
+
/** Idempotent methods: RFC 9110 s9.2.2. */
|
|
18
|
+
const IDEMPOTENT = new Set(["get", "head", "put", "delete"]);
|
|
19
|
+
/**
|
|
20
|
+
* What this emitter states about a method, and what it deliberately leaves unsaid.
|
|
21
|
+
*
|
|
22
|
+
* - `readOnlyHint: true` for a safe method. **`false` is never written**: it is MCP's default, so
|
|
23
|
+
* writing it says nothing while implying the emitter knows something it does not.
|
|
24
|
+
* - `idempotentHint` is written in BOTH directions, because the document's method makes both
|
|
25
|
+
* derivable. POST and PATCH are explicitly not idempotent, and saying so is a fact rather than a
|
|
26
|
+
* guess.
|
|
27
|
+
* - `destructiveHint: true` for DELETE only. **Omitted for POST and PATCH**, where MCP's default of
|
|
28
|
+
* `true` stands: the document gives no basis to contradict it, and an emitter must not invent one.
|
|
29
|
+
* - `openWorldHint` is never written. Whether an operation reaches beyond this service is not
|
|
30
|
+
* something any OpenAPI keyword states.
|
|
31
|
+
*/
|
|
32
|
+
export function annotationsFor(verb) {
|
|
33
|
+
const method = verb.toLowerCase();
|
|
34
|
+
const annotations = {};
|
|
35
|
+
if (SAFE.has(method))
|
|
36
|
+
annotations.readOnlyHint = true;
|
|
37
|
+
annotations.idempotentHint = IDEMPOTENT.has(method);
|
|
38
|
+
if (method === "delete")
|
|
39
|
+
annotations.destructiveHint = true;
|
|
40
|
+
return annotations;
|
|
41
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { EmittedResponse, EmittedRoute, RouteSchemaNames } from "typespec-http-zod";
|
|
2
|
+
/**
|
|
3
|
+
* **What each declared response body IS, decided once, for the renderer, the refusals and the runtime.**
|
|
4
|
+
*
|
|
5
|
+
* A handler's result type, the warning about a body nothing validates, the refusal of a body no tool
|
|
6
|
+
* result can carry, and the body kinds the runtime is told about all read the same four facts the
|
|
7
|
+
* library publishes per response: its media types, and whether its body is `binary`, `streamed` or
|
|
8
|
+
* `textual`. Deciding them in one place is what keeps the result type a handler compiles against and
|
|
9
|
+
* the rule the runtime serves it by from disagreeing.
|
|
10
|
+
*
|
|
11
|
+
* Pure: it reads the library's types and nothing else, so the renderer can use it without reaching
|
|
12
|
+
* the compiler.
|
|
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
|
+
/**
|
|
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.
|
|
26
|
+
*
|
|
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.
|
|
30
|
+
*/
|
|
31
|
+
export interface ResultMember {
|
|
32
|
+
readonly response: EmittedResponse;
|
|
33
|
+
/** The identifier the library declared this status's body under. */
|
|
34
|
+
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
|
+
}
|
|
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
|
+
export declare function membersOf(route: EmittedRoute, names: RouteSchemaNames): ResultMember[];
|
|
44
|
+
/**
|
|
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.
|
|
47
|
+
*
|
|
48
|
+
* - `"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.
|
|
50
|
+
*
|
|
51
|
+
* A body served as JSON needs no entry, because the served media type says so; nor does unvalidated
|
|
52
|
+
* text, which is what a non-JSON body with no entry is.
|
|
53
|
+
*/
|
|
54
|
+
export type BodyKind = "text" | "binary";
|
|
55
|
+
/**
|
|
56
|
+
* 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.
|
|
58
|
+
*
|
|
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.
|
|
63
|
+
*/
|
|
64
|
+
export declare function bodyKindsOf(route: EmittedRoute): readonly (readonly [string, BodyKind])[];
|
|
65
|
+
/** Each status whose body under some media type is answered unvalidated, with those media types. */
|
|
66
|
+
export declare function unvalidatedResponsesOf(route: EmittedRoute, names: RouteSchemaNames): readonly {
|
|
67
|
+
readonly status: EmittedResponse["status"];
|
|
68
|
+
readonly types: readonly string[];
|
|
69
|
+
}[];
|
|
@@ -0,0 +1,94 @@
|
|
|
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
|
+
}
|
|
45
|
+
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
|
+
});
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* 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.
|
|
67
|
+
*
|
|
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.
|
|
72
|
+
*/
|
|
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
|
+
});
|
|
84
|
+
}
|
|
85
|
+
/** Each status whose body under some media type is answered unvalidated, with those media types. */
|
|
86
|
+
export function unvalidatedResponsesOf(route, names) {
|
|
87
|
+
const members = membersOf(route, names);
|
|
88
|
+
return route.responses.flatMap((response) => {
|
|
89
|
+
const types = members
|
|
90
|
+
.filter((member) => member.response === response && bodyServingOf(member) === "unvalidated")
|
|
91
|
+
.flatMap((member) => (member.mediaType === undefined ? [] : [member.mediaType]));
|
|
92
|
+
return types.length === 0 ? [] : [{ status: response.status, types }];
|
|
93
|
+
});
|
|
94
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Program } from "@typespec/compiler";
|
|
2
|
+
export interface Collision {
|
|
3
|
+
readonly emitter: string;
|
|
4
|
+
/** The library options the two emitters resolve differently. */
|
|
5
|
+
readonly differing: readonly string[];
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Another emitter writing where this one writes, with library options that disagree.
|
|
9
|
+
*
|
|
10
|
+
* Returns nothing when the options MATCH: that arrangement produces byte-identical content and is
|
|
11
|
+
* the one worth encouraging.
|
|
12
|
+
*/
|
|
13
|
+
export declare function collisionsFor(program: Program, self: string, selfOutputDir: string): Collision[];
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **Two emitters writing one file is a silent last-writer-wins, and the winner is decided by the
|
|
3
|
+
* order of `emit:` in `tspconfig.yaml`.**
|
|
4
|
+
*
|
|
5
|
+
* `emitFile` has no duplicate-path detection -- measured in `@typespec/compiler@1.15.0`,
|
|
6
|
+
* `dist/src/core/emitter-utils.js:19-27`, which does `mkdirp`, records the path and writes. Two
|
|
7
|
+
* emitters that both run `typespec-http-zod` therefore both write `schemas.gen.ts`.
|
|
8
|
+
*
|
|
9
|
+
* Measured end to end on the published packages, with divergent `seal-object-schemas`:
|
|
10
|
+
*
|
|
11
|
+
* | `emit:` order | surviving `schemas.gen.ts` |
|
|
12
|
+
* | --- | --- |
|
|
13
|
+
* | server first, then library | 19 `z.object` -- unsealed |
|
|
14
|
+
* | library first, then server | 11 `z.object`, 8 `z.strictObject` -- sealed |
|
|
15
|
+
*
|
|
16
|
+
* Exit 0 both times, no diagnostic either time, and the two files differ. In the first row the
|
|
17
|
+
* server was generated believing schemas were sealed while the surviving file is not, so it
|
|
18
|
+
* validates more permissively than the emitter that wrote it believed.
|
|
19
|
+
*
|
|
20
|
+
* **The collision is opt-in, which is why this is a warning and not a refusal.** With nothing
|
|
21
|
+
* configured, `emitterOutputDir` defaults to `[outputDir, packageName]`
|
|
22
|
+
* (`dist/src/core/program.js:367-369`), so each emitter writes into a directory of its own and
|
|
23
|
+
* nothing collides. Only a consumer who deliberately points both at one directory can reach this --
|
|
24
|
+
* and that is a good arrangement when the options match, because it is what makes one set of
|
|
25
|
+
* validators serve both surfaces.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* The options that decide what `typespec-http-zod` writes.
|
|
29
|
+
*
|
|
30
|
+
* **Only these.** An emitter that shares a directory but does not accept them is not running the
|
|
31
|
+
* same library and is not contesting the same file -- `@typespec/openapi3` alongside a server
|
|
32
|
+
* emitter is the ordinary case, and warning about it would be noise.
|
|
33
|
+
*/
|
|
34
|
+
const SHARED_LIBRARY_OPTIONS = [
|
|
35
|
+
"contracts-output-dir",
|
|
36
|
+
"contracts-package",
|
|
37
|
+
"seal-object-schemas",
|
|
38
|
+
"compile-schemas",
|
|
39
|
+
"key-vocabularies",
|
|
40
|
+
"regenerate-hint",
|
|
41
|
+
];
|
|
42
|
+
/** Reproduces the compiler's own default, so a comparison means the same thing unconfigured. */
|
|
43
|
+
function outputDirOf(program, emitter) {
|
|
44
|
+
const options = program.compilerOptions.options?.[emitter];
|
|
45
|
+
const configured = options?.["emitter-output-dir"];
|
|
46
|
+
if (typeof configured === "string")
|
|
47
|
+
return configured;
|
|
48
|
+
const base = program.compilerOptions.outputDir;
|
|
49
|
+
return base === undefined ? undefined : `${base}/${emitter}`;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Whether an emitter runs `typespec-http-zod`, read from the option schema its library PUBLISHES.
|
|
53
|
+
*
|
|
54
|
+
* **Not from whether it was given one of these options.** That was the rule, and it held only while
|
|
55
|
+
* both emitters were configured with a `runtime-module`: an emitter running the library on its
|
|
56
|
+
* defaults sets none of them, and a sibling beside it with `compile-schemas: false` then rewrote
|
|
57
|
+
* `schemas.gen.ts` with nothing said. An emitter that accepts every one of these options is running
|
|
58
|
+
* the library that reads them, whatever it was configured with.
|
|
59
|
+
*/
|
|
60
|
+
function runsLibrary(program, emitter) {
|
|
61
|
+
const loaded = program.emitters.find((ref) => ref.metadata.name === emitter);
|
|
62
|
+
const declared = loaded?.library.definition?.emitter?.options?.properties;
|
|
63
|
+
return declared !== undefined && SHARED_LIBRARY_OPTIONS.every((key) => key in declared);
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Another emitter writing where this one writes, with library options that disagree.
|
|
67
|
+
*
|
|
68
|
+
* Returns nothing when the options MATCH: that arrangement produces byte-identical content and is
|
|
69
|
+
* the one worth encouraging.
|
|
70
|
+
*/
|
|
71
|
+
export function collisionsFor(program, self, selfOutputDir) {
|
|
72
|
+
const collisions = [];
|
|
73
|
+
const mine = program.compilerOptions.options?.[self];
|
|
74
|
+
for (const emitter of program.compilerOptions.emit ?? []) {
|
|
75
|
+
if (emitter === self)
|
|
76
|
+
continue;
|
|
77
|
+
if (outputDirOf(program, emitter) !== selfOutputDir)
|
|
78
|
+
continue;
|
|
79
|
+
if (!runsLibrary(program, emitter))
|
|
80
|
+
continue;
|
|
81
|
+
const theirs = program.compilerOptions.options?.[emitter];
|
|
82
|
+
const differing = SHARED_LIBRARY_OPTIONS.filter((key) => {
|
|
83
|
+
const a = mine?.[key];
|
|
84
|
+
const b = theirs?.[key];
|
|
85
|
+
return JSON.stringify(a ?? null) !== JSON.stringify(b ?? null);
|
|
86
|
+
});
|
|
87
|
+
if (differing.length > 0)
|
|
88
|
+
collisions.push({ emitter, differing });
|
|
89
|
+
}
|
|
90
|
+
return collisions;
|
|
91
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { EmitContext } from "@typespec/compiler";
|
|
2
|
+
/**
|
|
3
|
+
* Where emitted output imports its runtime from. Declared where the import is rendered, and
|
|
4
|
+
* re-exported here beside the code that writes the file.
|
|
5
|
+
*/
|
|
6
|
+
export { DEFAULT_RUNTIME_MODULE } from "./tools.js";
|
|
7
|
+
/**
|
|
8
|
+
* Run the library, then add the MCP surface beside what it wrote.
|
|
9
|
+
*
|
|
10
|
+
* **This file may import only `./lib.js`, its own modules, the library's public API and external
|
|
11
|
+
* packages.** The same rule the library's own emitter entry point holds itself to, and for the same
|
|
12
|
+
* reason: it makes "the published API is sufficient to build an emitter on" true by construction
|
|
13
|
+
* rather than by assertion.
|
|
14
|
+
*/
|
|
15
|
+
export declare function $onEmit(context: EmitContext): Promise<void>;
|