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
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
import { emitFile, NoTarget, resolvePath } from "@typespec/compiler";
|
|
4
|
+
import { getDoc, getSummary } from "@typespec/compiler";
|
|
5
|
+
import { emitHttpZod } from "typespec-http-zod";
|
|
6
|
+
import { PACKAGE_NAME, reportDiagnostic } from "./lib.js";
|
|
7
|
+
import { nameTools } from "./names.js";
|
|
8
|
+
import { collisionsFor } from "./coexistence.js";
|
|
9
|
+
import { refusalFor, warningsFor } from "./refusals.js";
|
|
10
|
+
import { renderTools } from "./tools.js";
|
|
11
|
+
import { httpOperationFor, selectTools } from "./select.js";
|
|
12
|
+
/**
|
|
13
|
+
* Where emitted output imports its runtime from. Declared where the import is rendered, and
|
|
14
|
+
* re-exported here beside the code that writes the file.
|
|
15
|
+
*/
|
|
16
|
+
export { DEFAULT_RUNTIME_MODULE } from "./tools.js";
|
|
17
|
+
/** Where this package's own runtime source lives, to be copied beside the generated code. */
|
|
18
|
+
const RUNTIME_SOURCE = fileURLToPath(new URL("../../src/runtime.ts", import.meta.url));
|
|
19
|
+
/** The header the emitted runtime carries, matching every other generated file. */
|
|
20
|
+
function generatedRuntimeBanner(hint) {
|
|
21
|
+
const second = hint === undefined
|
|
22
|
+
? "// A copy of this package's runtime, emitted so the generated code imports no package at run time."
|
|
23
|
+
: `// Regenerate with: ${hint}`;
|
|
24
|
+
return `// GENERATED by typespec-http-mcp. DO NOT EDIT.
|
|
25
|
+
${second}
|
|
26
|
+
`;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The options with `runtime-module` refused and removed, at the top level and per service.
|
|
30
|
+
*
|
|
31
|
+
* **Removed, not only reported**, because the library validates nothing about a key it no longer
|
|
32
|
+
* declares, and a wrapper passing options through should not hand it one. The same refusal the
|
|
33
|
+
* sibling server emitter makes, for the same option.
|
|
34
|
+
*/
|
|
35
|
+
function withoutRuntimeModule(context) {
|
|
36
|
+
const options = context.options;
|
|
37
|
+
const refuse = (value) => {
|
|
38
|
+
reportDiagnostic(context.program, {
|
|
39
|
+
code: "runtime-module-removed",
|
|
40
|
+
format: { value: String(value) },
|
|
41
|
+
target: NoTarget,
|
|
42
|
+
});
|
|
43
|
+
};
|
|
44
|
+
const { "runtime-module": topLevel, services, ...rest } = options;
|
|
45
|
+
if (topLevel !== undefined)
|
|
46
|
+
refuse(topLevel);
|
|
47
|
+
const kept = services === undefined
|
|
48
|
+
? undefined
|
|
49
|
+
: Object.fromEntries(Object.entries(services).map(([name, service]) => {
|
|
50
|
+
const { "runtime-module": perService, ...others } = service;
|
|
51
|
+
if (perService !== undefined)
|
|
52
|
+
refuse(perService);
|
|
53
|
+
return [name, others];
|
|
54
|
+
}));
|
|
55
|
+
return {
|
|
56
|
+
...context,
|
|
57
|
+
options: kept === undefined ? rest : { ...rest, services: kept },
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function resolveToolOptions(options, serviceName) {
|
|
61
|
+
const perService = options.services?.[serviceName];
|
|
62
|
+
return {
|
|
63
|
+
emitTools: perService?.["emit-tools"] ?? true,
|
|
64
|
+
toolTags: perService?.["tool-tags"] ?? options["tool-tags"],
|
|
65
|
+
excludeTags: perService?.["exclude-tags"] ?? options["exclude-tags"] ?? [],
|
|
66
|
+
toolPrefix: perService?.["tool-prefix"] ?? options["tool-prefix"],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Where a diagnostic about an operation should point.
|
|
71
|
+
*
|
|
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.
|
|
75
|
+
*/
|
|
76
|
+
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;
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Run the library, then add the MCP surface beside what it wrote.
|
|
84
|
+
*
|
|
85
|
+
* **This file may import only `./lib.js`, its own modules, the library's public API and external
|
|
86
|
+
* packages.** The same rule the library's own emitter entry point holds itself to, and for the same
|
|
87
|
+
* reason: it makes "the published API is sufficient to build an emitter on" true by construction
|
|
88
|
+
* rather than by assertion.
|
|
89
|
+
*/
|
|
90
|
+
export async function $onEmit(context) {
|
|
91
|
+
const options = context.options;
|
|
92
|
+
/**
|
|
93
|
+
* Raised once per program, before anything is written: it is about the arrangement, not about any
|
|
94
|
+
* one service, and a consumer needs to see it before they trust what landed on disk.
|
|
95
|
+
*/
|
|
96
|
+
for (const collision of collisionsFor(context.program, PACKAGE_NAME, context.emitterOutputDir)) {
|
|
97
|
+
reportDiagnostic(context.program, {
|
|
98
|
+
code: "shared-output-dir",
|
|
99
|
+
target: NoTarget,
|
|
100
|
+
format: { emitter: collision.emitter, differing: collision.differing.join(", ") },
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
for (const emitted of await emitHttpZod(withoutRuntimeModule(context))) {
|
|
104
|
+
await emitFile(context.program, {
|
|
105
|
+
path: resolvePath(emitted.outputDir, "mcp-runtime.gen.ts"),
|
|
106
|
+
content: `${generatedRuntimeBanner(emitted.options.regenerateHint)}${readFileSync(RUNTIME_SOURCE, "utf8")}`,
|
|
107
|
+
});
|
|
108
|
+
const serviceName = emitted.service.namespace.name;
|
|
109
|
+
const resolved = resolveToolOptions(options, serviceName);
|
|
110
|
+
if (!resolved.emitTools)
|
|
111
|
+
continue;
|
|
112
|
+
const selection = selectTools(context.program, emitted.service, emitted.routes, {
|
|
113
|
+
toolTags: resolved.toolTags,
|
|
114
|
+
excludeTags: resolved.excludeTags,
|
|
115
|
+
});
|
|
116
|
+
if (selection.selected.length === 0) {
|
|
117
|
+
reportDiagnostic(context.program, {
|
|
118
|
+
code: "no-tools-selected",
|
|
119
|
+
target: NoTarget,
|
|
120
|
+
format: {
|
|
121
|
+
service: serviceName,
|
|
122
|
+
configured: resolved.toolTags === undefined
|
|
123
|
+
? "(unset, so every operation)"
|
|
124
|
+
: resolved.toolTags.join(", "),
|
|
125
|
+
available: selection.available.length === 0 ? "(none)" : selection.available.join(", "),
|
|
126
|
+
},
|
|
127
|
+
});
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const named = nameTools(selection.selected, resolved.toolPrefix);
|
|
131
|
+
for (const refused of named.unrepresentable) {
|
|
132
|
+
reportDiagnostic(context.program, {
|
|
133
|
+
code: "unrepresentable-tool-name",
|
|
134
|
+
target: targetFor(emitted, refused.operationId),
|
|
135
|
+
format: { name: refused.name },
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
for (const collision of named.collisions) {
|
|
139
|
+
reportDiagnostic(context.program, {
|
|
140
|
+
code: "tool-name-collision",
|
|
141
|
+
target: targetFor(emitted, collision.second),
|
|
142
|
+
format: { first: collision.first, second: collision.second, name: collision.name },
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const entries = [];
|
|
146
|
+
/**
|
|
147
|
+
* Refusals counted as they happen, so the invariant below can be arithmetic rather than a
|
|
148
|
+
* guess. A silent drop is then impossible: the numbers stop adding up.
|
|
149
|
+
*/
|
|
150
|
+
let refused = named.unrepresentable.length + named.collisions.length * 2;
|
|
151
|
+
for (const route of emitted.routes) {
|
|
152
|
+
const toolName = named.names.get(route.operationId);
|
|
153
|
+
if (toolName === undefined)
|
|
154
|
+
continue;
|
|
155
|
+
const names = emitted.schemaNames.get(route.operationId);
|
|
156
|
+
const operation = httpOperationFor(emitted.service, route);
|
|
157
|
+
if (names === undefined || operation === undefined)
|
|
158
|
+
continue;
|
|
159
|
+
/** A refusal removes the operation from the surface, so it is counted, never skipped. */
|
|
160
|
+
const refusal = refusalFor(context.program, route, operation);
|
|
161
|
+
if (refusal !== undefined) {
|
|
162
|
+
refused += 1;
|
|
163
|
+
reportDiagnostic(context.program, {
|
|
164
|
+
code: refusal.kind,
|
|
165
|
+
target: targetFor(emitted, route.operationId),
|
|
166
|
+
format: { operationId: route.operationId, types: refusal.types },
|
|
167
|
+
});
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
const description = getDoc(context.program, operation.operation);
|
|
171
|
+
const title = getSummary(context.program, operation.operation);
|
|
172
|
+
const described = description !== undefined || title !== undefined;
|
|
173
|
+
for (const warning of warningsFor(route, names, described)) {
|
|
174
|
+
const target = targetFor(emitted, route.operationId);
|
|
175
|
+
if (warning.code === "unvalidated-response-media-type") {
|
|
176
|
+
reportDiagnostic(context.program, {
|
|
177
|
+
code: warning.code,
|
|
178
|
+
target,
|
|
179
|
+
format: { operationId: route.operationId, ...warning.format },
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
else {
|
|
183
|
+
reportDiagnostic(context.program, {
|
|
184
|
+
code: warning.code,
|
|
185
|
+
target,
|
|
186
|
+
format: { operationId: route.operationId },
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
entries.push({
|
|
191
|
+
route,
|
|
192
|
+
names,
|
|
193
|
+
toolName,
|
|
194
|
+
/**
|
|
195
|
+
* **Read from the IR, not re-derived.** The library publishes the document's own
|
|
196
|
+
* requirements as of 0.25.0; before that it published only a flat scope union and a
|
|
197
|
+
* boolean, so both server emitters rebuilt this by hand from a third read of the
|
|
198
|
+
* program. Two hand-written copies of one rule, neither compared against the other.
|
|
199
|
+
*/
|
|
200
|
+
requirements: route.security,
|
|
201
|
+
/**
|
|
202
|
+
* **The description a model reads to decide whether to call this tool.**
|
|
203
|
+
*
|
|
204
|
+
* `@doc` rather than `@summary`: the summary becomes the tool's `title`, which is for
|
|
205
|
+
* display. Both are published by `@typespec/openapi3`, so neither is a new fact.
|
|
206
|
+
*/
|
|
207
|
+
description,
|
|
208
|
+
/**
|
|
209
|
+
* **Read from `@summary` directly, not from `EmittedRoute.summary`.**
|
|
210
|
+
*
|
|
211
|
+
* The library's `summary` is `getSummary ?? getDoc`, which is right for its purposes and
|
|
212
|
+
* wrong here: with only a doc comment declared it would emit a `title` byte-identical to
|
|
213
|
+
* the `description`, so every tool would carry the same sentence twice in a payload the
|
|
214
|
+
* model reads. A title is for display and is absent unless the spec declares one.
|
|
215
|
+
*/
|
|
216
|
+
title,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* **`emitted + refused === selected`, asserted from structured counts rather than by reading
|
|
221
|
+
* the file back.** Deciding anything by pattern-matching emitted text is how this estate
|
|
222
|
+
* acquired a class of defect it then had to hunt for.
|
|
223
|
+
*/
|
|
224
|
+
if (entries.length + refused !== selection.selected.length) {
|
|
225
|
+
throw new Error(`typespec-http-mcp internal invariant: ${entries.length} emitted + ${refused} refused ` +
|
|
226
|
+
`!== ${selection.selected.length} selected for '${serviceName}'`);
|
|
227
|
+
}
|
|
228
|
+
const rendered = renderTools(entries, emitted.routes.map((route) => route.operationId), {
|
|
229
|
+
sealed: emitted.options.sealObjectSchemas,
|
|
230
|
+
hint: emitted.options.regenerateHint,
|
|
231
|
+
});
|
|
232
|
+
await emitFile(context.program, {
|
|
233
|
+
path: resolvePath(emitted.outputDir, "mcp.gen.ts"),
|
|
234
|
+
content: rendered.text,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public API.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports `typespec-http-zod` wholesale, as `typespec-hono` does, so a consumer building on this
|
|
5
|
+
* emitter has one specifier to import from rather than needing to know which package minted which
|
|
6
|
+
* name.
|
|
7
|
+
*/
|
|
8
|
+
export * from "typespec-http-zod";
|
|
9
|
+
export { DEFAULT_RUNTIME_MODULE, $onEmit } from "./emitter.js";
|
|
10
|
+
export { $lib, EmitterOptionsSchema, type EmitterOptions } from "./lib.js";
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The public API.
|
|
3
|
+
*
|
|
4
|
+
* Re-exports `typespec-http-zod` wholesale, as `typespec-hono` does, so a consumer building on this
|
|
5
|
+
* emitter has one specifier to import from rather than needing to know which package minted which
|
|
6
|
+
* name.
|
|
7
|
+
*/
|
|
8
|
+
export * from "typespec-http-zod";
|
|
9
|
+
export { DEFAULT_RUNTIME_MODULE, $onEmit } from "./emitter.js";
|
|
10
|
+
export { $lib, EmitterOptionsSchema } from "./lib.js";
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { type CallableMessage, type DiagnosticReport, type JSONSchemaType, type Program, type TypeSpecLibrary } from "@typespec/compiler";
|
|
2
|
+
import { type EmitterOptions as HttpZodOptions } from "typespec-http-zod";
|
|
3
|
+
/**
|
|
4
|
+
* Everything `typespec-http-zod` accepts, plus what an MCP tool surface needs on top.
|
|
5
|
+
*
|
|
6
|
+
* **DERIVED, never restated, and the distinction is load-bearing.** This emitter runs the whole of
|
|
7
|
+
* `typespec-http-zod` and adds files beside its output; every option that package accepts has to
|
|
8
|
+
* reach it. Written as a second literal list, an option added there would be rejected here as
|
|
9
|
+
* unknown, or worse, accepted and silently dropped -- output that is wrong in a way no test of
|
|
10
|
+
* either package would see. `test/options.test.ts` asserts the forwarding as a CLASS.
|
|
11
|
+
*
|
|
12
|
+
* **`runtime-module` is declared here only to be REFUSED** (`runtime-module-removed`). The library no
|
|
13
|
+
* longer has the option, and a schema without the key would report only "must NOT have additional
|
|
14
|
+
* properties", which tells a consumer upgrading from a version that documented the option neither why
|
|
15
|
+
* nor what to do instead.
|
|
16
|
+
*/
|
|
17
|
+
export type EmitterOptions = HttpZodOptions & {
|
|
18
|
+
/** Refused. See `runtime-module-removed`. */
|
|
19
|
+
"runtime-module"?: string;
|
|
20
|
+
/**
|
|
21
|
+
* Tags that mark an operation as a tool. Unset means every operation is one.
|
|
22
|
+
*
|
|
23
|
+
* **Union, not intersection.** OpenAPI tags are a flat set with no AND semantics, so "carries any
|
|
24
|
+
* of these" is the only reading the published document supports. Read with `getAllTags`, which
|
|
25
|
+
* includes tags inherited from the enclosing interface and namespace -- `getTags` returns only an
|
|
26
|
+
* operation's own, so an operation inside a `@tag`-carrying interface would report none and be
|
|
27
|
+
* silently dropped.
|
|
28
|
+
*/
|
|
29
|
+
"tool-tags"?: string[];
|
|
30
|
+
/** Tags that exclude an operation from the tool surface, even when `tool-tags` selected it. */
|
|
31
|
+
"exclude-tags"?: string[];
|
|
32
|
+
/**
|
|
33
|
+
* Prepended to every tool name, verbatim -- the author owns the separator.
|
|
34
|
+
*
|
|
35
|
+
* The result is validated against MCP's name rule and REFUSED if it fails, never rewritten: an
|
|
36
|
+
* operation id is a fact the document publishes, and re-spelling it invents a second name for
|
|
37
|
+
* one thing.
|
|
38
|
+
*/
|
|
39
|
+
"tool-prefix"?: string;
|
|
40
|
+
/**
|
|
41
|
+
* Per-service overrides this emitter adds on top of the library's.
|
|
42
|
+
*
|
|
43
|
+
* **The library's own `services` map is preserved**, because the type is an intersection: an
|
|
44
|
+
* option added there still arrives here, and this only widens what each entry may carry.
|
|
45
|
+
*/
|
|
46
|
+
services?: Record<string, {
|
|
47
|
+
"emit-tools"?: boolean;
|
|
48
|
+
"tool-tags"?: string[];
|
|
49
|
+
"exclude-tags"?: string[];
|
|
50
|
+
"tool-prefix"?: string;
|
|
51
|
+
/** Refused. See `runtime-module-removed`. */
|
|
52
|
+
"runtime-module"?: string;
|
|
53
|
+
}>;
|
|
54
|
+
};
|
|
55
|
+
declare const EmitterOptionsSchema: JSONSchemaType<EmitterOptions>;
|
|
56
|
+
/**
|
|
57
|
+
* **Spelled out rather than inferred, and every type in it imported through THIS package's own
|
|
58
|
+
* specifier.** Both this package and `typespec-http-zod` declare `@typespec/compiler` as a peer, so a
|
|
59
|
+
* side-by-side checkout resolves two physically distinct copies of the identical version. TypeScript
|
|
60
|
+
* then has a choice of which copy to name in the emitted declarations, and naming the other
|
|
61
|
+
* package's produces `TS2883`, whose message is exactly the problem: *"this is likely not portable"*.
|
|
62
|
+
*
|
|
63
|
+
* A published `.d.ts` naming a `node_modules/.pnpm/...` path is broken for everyone who installed
|
|
64
|
+
* differently. **A consumer never hits this**, because a peer dependency is installed once and both
|
|
65
|
+
* packages share it -- which is precisely why it is worth guarding rather than trusting: the failure
|
|
66
|
+
* exists only in the arrangement that BUILDS the package, and would ship silently.
|
|
67
|
+
*/
|
|
68
|
+
type Diagnostics = {
|
|
69
|
+
"no-tools-selected": {
|
|
70
|
+
readonly default: CallableMessage<["service", "configured", "available"]>;
|
|
71
|
+
};
|
|
72
|
+
"unrepresentable-tool-name": {
|
|
73
|
+
readonly default: CallableMessage<["name"]>;
|
|
74
|
+
};
|
|
75
|
+
"tool-name-collision": {
|
|
76
|
+
readonly default: CallableMessage<["first", "second", "name"]>;
|
|
77
|
+
};
|
|
78
|
+
"binary-tool-input": {
|
|
79
|
+
readonly default: CallableMessage<["operationId", "types"]>;
|
|
80
|
+
};
|
|
81
|
+
"non-json-request": {
|
|
82
|
+
readonly default: CallableMessage<["operationId", "types"]>;
|
|
83
|
+
};
|
|
84
|
+
"non-object-body": {
|
|
85
|
+
readonly default: CallableMessage<["operationId", "types"]>;
|
|
86
|
+
};
|
|
87
|
+
"binary-tool-output": {
|
|
88
|
+
readonly default: CallableMessage<["operationId", "types"]>;
|
|
89
|
+
};
|
|
90
|
+
"streamed-tool-output": {
|
|
91
|
+
readonly default: CallableMessage<["operationId", "types"]>;
|
|
92
|
+
};
|
|
93
|
+
"unvalidated-response-media-type": {
|
|
94
|
+
readonly default: CallableMessage<["operationId", "status", "types"]>;
|
|
95
|
+
};
|
|
96
|
+
"headers-only-success": {
|
|
97
|
+
readonly default: CallableMessage<["operationId"]>;
|
|
98
|
+
};
|
|
99
|
+
"tool-without-description": {
|
|
100
|
+
readonly default: CallableMessage<["operationId"]>;
|
|
101
|
+
};
|
|
102
|
+
"shared-output-dir": {
|
|
103
|
+
readonly default: CallableMessage<["emitter", "differing"]>;
|
|
104
|
+
};
|
|
105
|
+
"runtime-module-removed": {
|
|
106
|
+
readonly default: CallableMessage<["value"]>;
|
|
107
|
+
};
|
|
108
|
+
};
|
|
109
|
+
/**
|
|
110
|
+
* This emitter's name, as `tspconfig.yaml`, the compiler and every diagnostic code all spell it.
|
|
111
|
+
*
|
|
112
|
+
* **One literal, because two would be a guard that stops working silently.** `coexistence.ts` looks
|
|
113
|
+
* this emitter up in `program.compilerOptions.options` by name; a second copy that drifted would
|
|
114
|
+
* find nothing and report no collision, with nothing to indicate the check had stopped running.
|
|
115
|
+
*/
|
|
116
|
+
export declare const PACKAGE_NAME = "typespec-http-mcp";
|
|
117
|
+
export declare const $lib: TypeSpecLibrary<Diagnostics, EmitterOptions>;
|
|
118
|
+
export declare const reportDiagnostic: <C extends keyof Diagnostics, M extends keyof Diagnostics[C]>(program: Program, diagnostic: DiagnosticReport<Diagnostics, C, M>) => void;
|
|
119
|
+
export { EmitterOptionsSchema };
|
package/dist/src/lib.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { createTypeSpecLibrary, paramMessage, } from "@typespec/compiler";
|
|
2
|
+
import { EmitterOptionsSchema as httpZodOptions, } from "typespec-http-zod";
|
|
3
|
+
const toolSelection = {
|
|
4
|
+
"tool-tags": { type: "array", items: { type: "string" }, nullable: true },
|
|
5
|
+
"exclude-tags": { type: "array", items: { type: "string" }, nullable: true },
|
|
6
|
+
"tool-prefix": { type: "string", nullable: true },
|
|
7
|
+
};
|
|
8
|
+
/**
|
|
9
|
+
* **A spread of the published schema, so a new key arrives for free.**
|
|
10
|
+
*
|
|
11
|
+
* `properties` is spread rather than re-listed for the same reason the type is aliased rather than
|
|
12
|
+
* re-declared. The MCP-only keys are added to that spread, never by copying the library's list.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* The published schema, narrowed to the object variant it actually is.
|
|
16
|
+
*
|
|
17
|
+
* **`JSONSchemaType<T>` is a UNION of variants** -- an object schema, an `anyOf`, and others --
|
|
18
|
+
* and spreading a union-typed value yields a union, which then matches no single target. The value
|
|
19
|
+
* `typespec-http-zod` publishes is the object variant (measured: its runtime keys are `type`,
|
|
20
|
+
* `additionalProperties`, `properties`, `required`, with `type === "object"`). Narrowing once here
|
|
21
|
+
* is what keeps the four spreads below honest rather than casting at each of them, and
|
|
22
|
+
* `test/options.test.ts` grades the RESULT as a class, so a narrowing that ever became untrue would
|
|
23
|
+
* surface as a missing option rather than as a silent type hole.
|
|
24
|
+
*/
|
|
25
|
+
const httpZodObject = httpZodOptions;
|
|
26
|
+
const EmitterOptionsSchema = {
|
|
27
|
+
...httpZodObject,
|
|
28
|
+
properties: {
|
|
29
|
+
...httpZodObject.properties,
|
|
30
|
+
...toolSelection,
|
|
31
|
+
// Accepted by the schema so that setting it reaches `runtime-module-removed`.
|
|
32
|
+
"runtime-module": { type: "string", nullable: true },
|
|
33
|
+
/**
|
|
34
|
+
* **Spread from the library's own entry rather than restated**, so a per-service option added
|
|
35
|
+
* there still validates here.
|
|
36
|
+
*/
|
|
37
|
+
services: {
|
|
38
|
+
...httpZodObject.properties.services,
|
|
39
|
+
additionalProperties: {
|
|
40
|
+
type: "object",
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
properties: {
|
|
43
|
+
...httpZodObject.properties.services.additionalProperties.properties,
|
|
44
|
+
...toolSelection,
|
|
45
|
+
"emit-tools": { type: "boolean", nullable: true },
|
|
46
|
+
"runtime-module": { type: "string", nullable: true },
|
|
47
|
+
},
|
|
48
|
+
required: [],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
/**
|
|
53
|
+
* **Asserted, and the assertion is graded rather than trusted.**
|
|
54
|
+
*
|
|
55
|
+
* The spread above carries the whole published schema, which is what keeps a new library option
|
|
56
|
+
* arriving here for free. It also carries `dependencies`, `dependentRequired` and
|
|
57
|
+
* `dependentSchemas`, whose TYPES are written over `keyof HttpZodOptions`. This emitter's option
|
|
58
|
+
* type adds three keys the library does not have, so those families no longer match under
|
|
59
|
+
* `exactOptionalPropertyTypes` -- `TS2375`. None of them is present at run time (measured: the
|
|
60
|
+
* published object's only keys are `type`, `additionalProperties`, `properties` and `required`)
|
|
61
|
+
* and this schema uses none of them.
|
|
62
|
+
*
|
|
63
|
+
* `typespec-hono` does not hit this because the only key it adds, `services`, the library already
|
|
64
|
+
* has, so its key set is identical.
|
|
65
|
+
*
|
|
66
|
+
* **What stops this hiding a real defect is `test/options.test.ts`**, which asserts as a CLASS
|
|
67
|
+
* that every option the library publishes appears here, top level and per service. A spread that
|
|
68
|
+
* silently stopped forwarding would fail there, where a type error would no longer.
|
|
69
|
+
*/
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* What this emitter refuses that the library does not.
|
|
73
|
+
*
|
|
74
|
+
* The library refuses what no OpenAPI document can state. Everything here is narrower: what no MCP
|
|
75
|
+
* TOOL can express. Two different questions, and keeping them in separate libraries is what stops a
|
|
76
|
+
* consumer who wants validators without a tool surface inheriting a refusal about tools.
|
|
77
|
+
*
|
|
78
|
+
* **Warnings about the document, never errors.** A TypeSpec `error` sets `program.hasError()`, and
|
|
79
|
+
* `@typespec/openapi3` then writes nothing -- so an error here would cost the consumer their whole
|
|
80
|
+
* document over one operation this emitter happens not to like. The one error is about the
|
|
81
|
+
* CONFIGURATION, `runtime-module-removed`, where a warning would let a setting be silently ignored.
|
|
82
|
+
*/
|
|
83
|
+
const diagnostics = {
|
|
84
|
+
/**
|
|
85
|
+
* The selection matched nothing, for any reason: a typo in `tool-tags`, an `exclude-tags` that
|
|
86
|
+
* swallowed everything, an explicit empty list.
|
|
87
|
+
*
|
|
88
|
+
* The message names the tags the service ACTUALLY declares alongside the ones configured, because
|
|
89
|
+
* that is the cheap half of the fix and the emitter already has it.
|
|
90
|
+
*/
|
|
91
|
+
"no-tools-selected": {
|
|
92
|
+
severity: "warning",
|
|
93
|
+
messages: {
|
|
94
|
+
default: paramMessage `No operation in '${"service"}' was selected as a tool. Configured tool-tags: ${"configured"}. Tags this service declares: ${"available"}. Adjust 'tool-tags' or 'exclude-tags', or remove them to select every operation.`,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
/**
|
|
98
|
+
* A tool name MCP cannot carry.
|
|
99
|
+
*
|
|
100
|
+
* Refused rather than rewritten. An operation id is a contract fact the document publishes; a
|
|
101
|
+
* generated second spelling of it is a name no reader of the document can predict. TypeSpec
|
|
102
|
+
* permits characters MCP's rule does not, so this fires for real specs rather than only in theory.
|
|
103
|
+
*/
|
|
104
|
+
"unrepresentable-tool-name": {
|
|
105
|
+
severity: "warning",
|
|
106
|
+
messages: {
|
|
107
|
+
default: paramMessage `'${"name"}' is not a name MCP can carry: a tool name is 1 to 128 characters of A-Z, a-z, 0-9, '_', '-' or '.'. Name the operation with '@operationId("...")', or shorten 'tool-prefix'. This emitter will not re-spell a published operation id.`,
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
/**
|
|
111
|
+
* Another emitter writes where this one writes, with options that disagree.
|
|
112
|
+
*
|
|
113
|
+
* Fires only when the resolved LIBRARY options differ. Sharing a directory with matching options
|
|
114
|
+
* is the arrangement that makes one set of validators serve both surfaces, and it is silent.
|
|
115
|
+
*/
|
|
116
|
+
"shared-output-dir": {
|
|
117
|
+
severity: "warning",
|
|
118
|
+
messages: {
|
|
119
|
+
default: paramMessage `'${"emitter"}' writes to the same emitter-output-dir as this emitter with different options (${"differing"}), so 'schemas.gen.ts' is written twice with different content and whichever emitter is listed LAST in 'emit:' wins. Give each emitter its own 'emitter-output-dir', or set the same values on both.`,
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
/**
|
|
123
|
+
* A request body an MCP tool argument cannot carry.
|
|
124
|
+
*
|
|
125
|
+
* A tool's arguments are JSON. A raw `bytes` body, a stream, or a `multipart/form-data` upload is
|
|
126
|
+
* not JSON and cannot become one without inventing an encoding the document never states.
|
|
127
|
+
* **Refused rather than approximated**: base64-ing a body into a string argument would publish a
|
|
128
|
+
* contract no reader of the document could predict, and would put a whole upload through a model's
|
|
129
|
+
* context window.
|
|
130
|
+
*/
|
|
131
|
+
"binary-tool-input": {
|
|
132
|
+
severity: "warning",
|
|
133
|
+
messages: {
|
|
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
|
+
},
|
|
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
|
+
/**
|
|
154
|
+
* A streamed response, which a tool result cannot be.
|
|
155
|
+
*
|
|
156
|
+
* An SSE or JSONL response is unbounded and arrives over time; a tool call answers with one message.
|
|
157
|
+
* Buffering waits on a stream that may never end, and answering the first event would publish a
|
|
158
|
+
* contract no reader of the document could predict.
|
|
159
|
+
*/
|
|
160
|
+
"streamed-tool-output": {
|
|
161
|
+
severity: "warning",
|
|
162
|
+
messages: {
|
|
163
|
+
default: paramMessage `'${"operationId"}' answers with a stream (${"types"}), and an MCP tool result is one message. Exclude it with 'exclude-tags', or declare an operation that answers once for the tool surface.`,
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
/**
|
|
167
|
+
* A response the handler supplies as text, answered without being checked against its schema.
|
|
168
|
+
*
|
|
169
|
+
* A model declared under a media type that is not JSON -- an `Item` as `application/xml` -- has no
|
|
170
|
+
* serialisation this emitter can derive from the document, so the handler returns the text and the
|
|
171
|
+
* tool answers it as is. The same code, and the same trigger, as the sibling server emitter's, so a
|
|
172
|
+
* project emitting both surfaces reads one warning about one response.
|
|
173
|
+
*/
|
|
174
|
+
"unvalidated-response-media-type": {
|
|
175
|
+
severity: "warning",
|
|
176
|
+
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.`,
|
|
178
|
+
},
|
|
179
|
+
},
|
|
180
|
+
/**
|
|
181
|
+
* A request body with no properties to merge into a tool's argument object.
|
|
182
|
+
*
|
|
183
|
+
* A tool takes ONE object. The document states the body separately from the parameters, so the
|
|
184
|
+
* two are merged -- and a scalar, enum, union or array body has nothing to merge. Naming it under
|
|
185
|
+
* an invented property would publish a contract no reader of the document could predict.
|
|
186
|
+
*/
|
|
187
|
+
"non-object-body": {
|
|
188
|
+
severity: "warning",
|
|
189
|
+
messages: {
|
|
190
|
+
default: paramMessage `'${"operationId"}' has a request body that is not an object (${"types"}), and an MCP tool's arguments are one object with named properties. Wrap it in a model with a named property, or exclude the operation with 'exclude-tags'.`,
|
|
191
|
+
},
|
|
192
|
+
},
|
|
193
|
+
/**
|
|
194
|
+
* Every declared request media type is one a tool cannot send.
|
|
195
|
+
*
|
|
196
|
+
* Fires only where NO JSON media type is declared beside them. Where one is, the tool sends JSON
|
|
197
|
+
* and the others are simply unreachable from MCP, which is not a defect worth a diagnostic.
|
|
198
|
+
*/
|
|
199
|
+
"non-json-request": {
|
|
200
|
+
severity: "warning",
|
|
201
|
+
messages: {
|
|
202
|
+
default: paramMessage `'${"operationId"}' declares only non-JSON request media types (${"types"}), and an MCP tool sends JSON. Declare 'application/json' alongside them.`,
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
/**
|
|
206
|
+
* A success that carries headers and no body.
|
|
207
|
+
*
|
|
208
|
+
* The tool would return nothing a model can read: response headers go to `_meta`, which a model
|
|
209
|
+
* does not see. **Not a refusal** -- the tool is still emitted, because the operation is callable
|
|
210
|
+
* and its effect may be the point. It is a warning because the value is invisible where it
|
|
211
|
+
* matters.
|
|
212
|
+
*/
|
|
213
|
+
"headers-only-success": {
|
|
214
|
+
severity: "warning",
|
|
215
|
+
messages: {
|
|
216
|
+
default: paramMessage `'${"operationId"}' answers with response headers and no body, so its tool returns nothing a model can read: headers are carried in '_meta', which a model does not see. Declare the value in the response body.`,
|
|
217
|
+
},
|
|
218
|
+
},
|
|
219
|
+
/**
|
|
220
|
+
* `runtime-module` is set, and this emitter no longer substitutes its runtime.
|
|
221
|
+
*
|
|
222
|
+
* **The option made an application OWN a copy of generated logic**, because the generated tools
|
|
223
|
+
* imported the application's `Ctx` and `Result` from the same module as arm selection and the
|
|
224
|
+
* tool-result rules. So every consumer that needed its own caller context or result shape copied
|
|
225
|
+
* those too, and a copy ages with every release it is not updated for.
|
|
226
|
+
*
|
|
227
|
+
* Nothing the option was for needs it now. The caller context is inferred from `deps.context`, and
|
|
228
|
+
* what a handler returns is the union of the responses the document declares. **An error rather
|
|
229
|
+
* than a warning**, because the option's value is ignored, and an emitter that silently ignores a
|
|
230
|
+
* setting produces output its author did not ask for.
|
|
231
|
+
*/
|
|
232
|
+
"runtime-module-removed": {
|
|
233
|
+
severity: "error",
|
|
234
|
+
messages: {
|
|
235
|
+
default: paramMessage `'runtime-module' is set to '${"value"}', and typespec-http-mcp no longer substitutes its runtime: 'mcp-runtime.gen.ts' is always emitted. Remove the option and delete the module it pointed at. The caller context is inferred from 'deps.context', and a handler returns '{ status, body, headers }' for any response the document declares. See the Upgrading section of docs/guides.md.`,
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
/**
|
|
239
|
+
* A selected operation with nothing for a model to read.
|
|
240
|
+
*
|
|
241
|
+
* A model chooses a tool by its description. Fires only for SELECTED operations: an opinion about
|
|
242
|
+
* an operation that is not a tool is not this emitter's business.
|
|
243
|
+
*/
|
|
244
|
+
"tool-without-description": {
|
|
245
|
+
severity: "warning",
|
|
246
|
+
messages: {
|
|
247
|
+
default: paramMessage `'${"operationId"}' becomes a tool with no description. A model chooses tools by their description, so an undescribed one is unlikely to be called correctly. Add a doc comment, or '@summary'.`,
|
|
248
|
+
},
|
|
249
|
+
},
|
|
250
|
+
/**
|
|
251
|
+
* Two selected operations produce one tool name.
|
|
252
|
+
*
|
|
253
|
+
* **BOTH are refused, and that is deliberate.** Keeping one and dropping the other would make the
|
|
254
|
+
* tool surface depend on which operation the emitter happened to reach first -- a surface that
|
|
255
|
+
* works and is wrong, which is strictly worse than one that is absent and says so.
|
|
256
|
+
*/
|
|
257
|
+
"tool-name-collision": {
|
|
258
|
+
severity: "warning",
|
|
259
|
+
messages: {
|
|
260
|
+
default: paramMessage `'${"first"}' and '${"second"}' both produce the tool name '${"name"}', so neither is emitted. Give one of them an '@operationId', or set a per-service 'tool-prefix'.`,
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
};
|
|
264
|
+
/**
|
|
265
|
+
* This emitter's name, as `tspconfig.yaml`, the compiler and every diagnostic code all spell it.
|
|
266
|
+
*
|
|
267
|
+
* **One literal, because two would be a guard that stops working silently.** `coexistence.ts` looks
|
|
268
|
+
* this emitter up in `program.compilerOptions.options` by name; a second copy that drifted would
|
|
269
|
+
* find nothing and report no collision, with nothing to indicate the check had stopped running.
|
|
270
|
+
*/
|
|
271
|
+
export const PACKAGE_NAME = "typespec-http-mcp";
|
|
272
|
+
export const $lib = createTypeSpecLibrary({
|
|
273
|
+
name: PACKAGE_NAME,
|
|
274
|
+
diagnostics,
|
|
275
|
+
emitter: { options: EmitterOptionsSchema },
|
|
276
|
+
});
|
|
277
|
+
export const reportDiagnostic = $lib.reportDiagnostic;
|
|
278
|
+
export { EmitterOptionsSchema };
|