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 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>;