typespec-http-mcp 0.2.0 → 0.4.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/dist/src/lib.d.ts +3 -0
- package/dist/src/lib.js +17 -0
- package/dist/src/refusals.d.ts +3 -0
- package/dist/src/refusals.js +29 -1
- package/dist/src/tools.js +33 -15
- package/package.json +3 -3
package/dist/src/lib.d.ts
CHANGED
|
@@ -75,6 +75,9 @@ type Diagnostics = {
|
|
|
75
75
|
"tool-name-collision": {
|
|
76
76
|
readonly default: CallableMessage<["first", "second", "name"]>;
|
|
77
77
|
};
|
|
78
|
+
"shared-input-key": {
|
|
79
|
+
readonly default: CallableMessage<["operationId", "types"]>;
|
|
80
|
+
};
|
|
78
81
|
"binary-tool-input": {
|
|
79
82
|
readonly default: CallableMessage<["operationId", "types"]>;
|
|
80
83
|
};
|
package/dist/src/lib.js
CHANGED
|
@@ -128,6 +128,23 @@ const diagnostics = {
|
|
|
128
128
|
* contract no reader of the document could predict, and would put a whole upload through a model's
|
|
129
129
|
* context window.
|
|
130
130
|
*/
|
|
131
|
+
/**
|
|
132
|
+
* **An operation two of whose inputs would arrive as one argument is not a tool.**
|
|
133
|
+
*
|
|
134
|
+
* A tool's arguments are the path, query, header and cookie groups spread into one object, the
|
|
135
|
+
* body merged or named beside them - the same input a server's handler receives. `@header("x")`
|
|
136
|
+
* beside `@query("x")` therefore published one `x` argument, the later group's schema replacing
|
|
137
|
+
* the earlier, and the handler was handed one value for two declarations (typespec-http-graphql,
|
|
138
|
+
* 2026-10-16). `typespec-http-zod` names the pair in `EmittedRoute.sharedInputKeys` and reports it
|
|
139
|
+
* as its own `shared-input-key`; this is the tool surface declining it, as `typespec-hono` declines
|
|
140
|
+
* to mount it.
|
|
141
|
+
*/
|
|
142
|
+
"shared-input-key": {
|
|
143
|
+
severity: "warning",
|
|
144
|
+
messages: {
|
|
145
|
+
default: paramMessage `'${"operationId"}' is not a tool: ${"types"} would arrive as one argument, which holds one value. Give one of them a different name, or exclude the operation with 'exclude-tags'.`,
|
|
146
|
+
},
|
|
147
|
+
},
|
|
131
148
|
"binary-tool-input": {
|
|
132
149
|
severity: "warning",
|
|
133
150
|
messages: {
|
package/dist/src/refusals.d.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { type Program } from "@typespec/compiler";
|
|
|
2
2
|
import type { HttpOperation } from "@typespec/http";
|
|
3
3
|
import type { EmittedRoute, RouteSchemaNames } from "typespec-http-zod";
|
|
4
4
|
export type Refusal = {
|
|
5
|
+
readonly kind: "shared-input-key";
|
|
6
|
+
readonly types: string;
|
|
7
|
+
} | {
|
|
5
8
|
readonly kind: "binary-tool-input";
|
|
6
9
|
readonly types: string;
|
|
7
10
|
} | {
|
package/dist/src/refusals.js
CHANGED
|
@@ -8,6 +8,14 @@ import { unvalidatedResponsesOf } from "./bodies.js";
|
|
|
8
8
|
* arithmetically impossible. A WARNING leaves the tool in place and says something about its quality.
|
|
9
9
|
* Conflating them would let a quality complaint quietly delete a tool.
|
|
10
10
|
*/
|
|
11
|
+
/** How a `shared-input-key` refusal names where each of the two inputs comes from. */
|
|
12
|
+
const INPUT_LOCATION_NOUNS = {
|
|
13
|
+
path: "path parameter",
|
|
14
|
+
query: "query parameter",
|
|
15
|
+
header: "header",
|
|
16
|
+
cookie: "cookie",
|
|
17
|
+
body: "body",
|
|
18
|
+
};
|
|
11
19
|
/** A media type a tool can actually send. `application/json`, and the `+json` structured suffix. */
|
|
12
20
|
function isJson(contentType) {
|
|
13
21
|
const type = contentType.split(";")[0]?.trim().toLowerCase() ?? "";
|
|
@@ -70,6 +78,21 @@ function isMergeableBody(program, type) {
|
|
|
70
78
|
*/
|
|
71
79
|
export function refusalFor(program, route, operation) {
|
|
72
80
|
const types = route.requestContentTypes;
|
|
81
|
+
/**
|
|
82
|
+
* **Two inputs under one key cannot both be arguments.** A tool's arguments are the parameter
|
|
83
|
+
* groups spread into one object, as a server's handler input is, so `@header("x")` beside
|
|
84
|
+
* `@query("x")` published one `x` whose later schema replaced the earlier, and the handler was
|
|
85
|
+
* handed one value for two declarations. The library names every such pair; the operation is not
|
|
86
|
+
* a tool, for the reason `typespec-hono` does not mount it.
|
|
87
|
+
*/
|
|
88
|
+
if (route.sharedInputKeys.length > 0) {
|
|
89
|
+
return {
|
|
90
|
+
kind: "shared-input-key",
|
|
91
|
+
types: route.sharedInputKeys
|
|
92
|
+
.map((shared) => `the ${INPUT_LOCATION_NOUNS[shared.first]} '${shared.key}' and the ${INPUT_LOCATION_NOUNS[shared.second]} '${shared.key}'`)
|
|
93
|
+
.join("; "),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
73
96
|
/**
|
|
74
97
|
* A raw body is bytes or a stream; a multipart body is parts, one of which is usually a file.
|
|
75
98
|
* Neither is a JSON value, and encoding one into a string argument would put an upload through a
|
|
@@ -101,7 +124,12 @@ export function refusalFor(program, route, operation) {
|
|
|
101
124
|
* body is merged into the argument object, so it must have properties to merge.
|
|
102
125
|
*/
|
|
103
126
|
if (route.bodyProperty === undefined) {
|
|
104
|
-
|
|
127
|
+
/**
|
|
128
|
+
* **A body declared `void` or `never` is no body**, and the library lists no media type for
|
|
129
|
+
* one; read that way, there is nothing to merge. Read off the operation, its intrinsic type is
|
|
130
|
+
* not an object and the tool was refused as `non-object-body`.
|
|
131
|
+
*/
|
|
132
|
+
const body = types.length === 0 ? undefined : operation.parameters.body?.type;
|
|
105
133
|
if (!isMergeableBody(program, body)) {
|
|
106
134
|
return { kind: "non-object-body", types: body?.kind ?? "unknown" };
|
|
107
135
|
}
|
package/dist/src/tools.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsDocComment, mediaTypeTypeOf, objectKey, statusCodesOf, } from "typespec-http-zod";
|
|
1
|
+
import { jsDocComment, mediaTypeTypeOf, literal, objectKey, statusCodesOf, } from "typespec-http-zod";
|
|
2
2
|
import { annotationsFor } from "./annotations.js";
|
|
3
3
|
import { bodyKindsOf, isJsonMediaType, membersOf } from "./bodies.js";
|
|
4
4
|
/**
|
|
@@ -14,7 +14,7 @@ import { bodyKindsOf, isJsonMediaType, membersOf } from "./bodies.js";
|
|
|
14
14
|
function renderSecurity(requirements) {
|
|
15
15
|
return `[${requirements
|
|
16
16
|
.map((requirement) => `{ ${Object.entries(requirement)
|
|
17
|
-
.map(([scheme, scopes]) => `${
|
|
17
|
+
.map(([scheme, scopes]) => `${literal(scheme)}: [${scopes.map((s) => literal(s)).join(", ")}]`)
|
|
18
18
|
.join(", ")} }`)
|
|
19
19
|
.join(", ")}]`;
|
|
20
20
|
}
|
|
@@ -162,7 +162,7 @@ function selectedAcceptOf(entry) {
|
|
|
162
162
|
const selected = entry.names.selectedAccept;
|
|
163
163
|
return selected === undefined
|
|
164
164
|
? undefined
|
|
165
|
-
: `${objectKey(selected.name)}: ${
|
|
165
|
+
: `${objectKey(selected.name)}: ${literal(selected.value)}`;
|
|
166
166
|
}
|
|
167
167
|
/**
|
|
168
168
|
* The tool's ARGUMENT schema: every group the document declares, merged into one object.
|
|
@@ -182,6 +182,24 @@ function selectedAcceptOf(entry) {
|
|
|
182
182
|
function inputSchemaOf(entry, sealed) {
|
|
183
183
|
const constructor = sealed ? "z.strictObject" : "z.looseObject";
|
|
184
184
|
const spreads = shapedValidators(entry).map((name) => `...${name}.shape`);
|
|
185
|
+
/**
|
|
186
|
+
* **A decoded path parameter takes its VALUE schema, not the path validator's field.** That field
|
|
187
|
+
* is `z.preprocess(<decode>, <value schema>)`: it reads the segment TEXT a router extracts -
|
|
188
|
+
* `primitive.a` for `{.param}` - and hands on `a`. A tool argument is already the value, so through
|
|
189
|
+
* the decoder `{ param: "a" }` was refused as not the expansion the route declares, while
|
|
190
|
+
* `tools/list` published `param` as a plain string, and a value starting with the route's literal
|
|
191
|
+
* was rewritten without an error (issue #1). Six `@typespec/http-specs` path scenarios failed.
|
|
192
|
+
*
|
|
193
|
+
* Which parameters are decoded is the library's fact, `decodedPathParameters`, read rather than
|
|
194
|
+
* re-derived, so this cannot drift from the validator it overrides. The override comes after the
|
|
195
|
+
* spread, so it wins.
|
|
196
|
+
*/
|
|
197
|
+
const path = entry.names.path;
|
|
198
|
+
if (path !== undefined) {
|
|
199
|
+
for (const name of entry.route.decodedPathParameters) {
|
|
200
|
+
spreads.push(`${objectKey(name)}: ${path}.shape[${literal(name)}].out`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
185
203
|
if (entry.route.bodyProperty !== undefined && entry.names.body !== undefined) {
|
|
186
204
|
/**
|
|
187
205
|
* **Optional where the document lets the body be absent.** It was required here while the
|
|
@@ -351,7 +369,7 @@ export function renderTools(entries, declaredOperationIds, options) {
|
|
|
351
369
|
}));
|
|
352
370
|
if (usesByteStream)
|
|
353
371
|
runtimeTypes.push("ByteStream");
|
|
354
|
-
const from =
|
|
372
|
+
const from = literal(DEFAULT_RUNTIME_MODULE);
|
|
355
373
|
const runtimeImport = [
|
|
356
374
|
...(entries.length > 0 ? [`import { toolResult } from ${from};`] : []),
|
|
357
375
|
`import type { ${runtimeTypes.toSorted().join(", ")} } from ${from};`,
|
|
@@ -396,10 +414,10 @@ export function renderTools(entries, declaredOperationIds, options) {
|
|
|
396
414
|
.join("\n");
|
|
397
415
|
/** Every scope any selected operation demands, de-duplicated and ordered. */
|
|
398
416
|
const scopes = [...new Set(entries.flatMap((entry) => [...entry.route.scopes]))].toSorted();
|
|
399
|
-
const scopesLiteral = `[${scopes.map((scope) =>
|
|
417
|
+
const scopesLiteral = `[${scopes.map((scope) => literal(scope)).join(", ")}]`;
|
|
400
418
|
const declaredUnion = declaredOperationIds.length === 0
|
|
401
419
|
? "never"
|
|
402
|
-
: declaredOperationIds.map((id) =>
|
|
420
|
+
: declaredOperationIds.map((id) => literal(id)).join(" | ");
|
|
403
421
|
/**
|
|
404
422
|
* **Registered in a deterministic order, sorted by tool name.**
|
|
405
423
|
*
|
|
@@ -410,10 +428,10 @@ export function renderTools(entries, declaredOperationIds, options) {
|
|
|
410
428
|
const ordered = [...entries].toSorted((a, b) => a.toolName.localeCompare(b.toolName));
|
|
411
429
|
const registrations = ordered
|
|
412
430
|
.map((entry) => {
|
|
413
|
-
const title = entry.title === undefined ? "" : `\n\t\t\t\ttitle: ${
|
|
431
|
+
const title = entry.title === undefined ? "" : `\n\t\t\t\ttitle: ${literal(entry.title)},`;
|
|
414
432
|
const description = entry.description === undefined
|
|
415
433
|
? ""
|
|
416
|
-
: `\n\t\t\t\tdescription: ${
|
|
434
|
+
: `\n\t\t\t\tdescription: ${literal(entry.description)},`;
|
|
417
435
|
const published = outputSchemaOf(entry);
|
|
418
436
|
const outputSchema = published === undefined ? "" : `\n\t\t\t\toutputSchema: ${published},`;
|
|
419
437
|
/**
|
|
@@ -424,12 +442,12 @@ export function renderTools(entries, declaredOperationIds, options) {
|
|
|
424
442
|
for (const [status, mediaType, kind] of bodyKindsOf(entry.route, entry.names)) {
|
|
425
443
|
kinds.set(status, [
|
|
426
444
|
...(kinds.get(status) ?? []),
|
|
427
|
-
`${
|
|
445
|
+
`${literal(mediaType)}: ${literal(kind)}`,
|
|
428
446
|
]);
|
|
429
447
|
}
|
|
430
448
|
const bodies = kinds.size === 0
|
|
431
449
|
? ""
|
|
432
|
-
: `, bodies: { ${[...kinds].map(([status, byType]) => `${
|
|
450
|
+
: `, bodies: { ${[...kinds].map(([status, byType]) => `${literal(status)}: { ${byType.join(", ")} }`).join(", ")} }`;
|
|
433
451
|
const declared = `{ arms: ${entry.names.responses}, outputSchema: ${published ?? "undefined"}${bodies} }`;
|
|
434
452
|
/**
|
|
435
453
|
* **A parameterless tool must not DECLARE an argument it never reads.**
|
|
@@ -450,23 +468,23 @@ export function renderTools(entries, declaredOperationIds, options) {
|
|
|
450
468
|
const argumentName = parameterless ? "_args" : "args";
|
|
451
469
|
return ` {
|
|
452
470
|
const tool = server.registerTool(
|
|
453
|
-
${
|
|
471
|
+
${literal(entry.toolName)},
|
|
454
472
|
{${title}${description}
|
|
455
473
|
inputSchema: ${inputSchemaName(entry)},${outputSchema}
|
|
456
474
|
annotations: ${renderAnnotations(annotationsFor(entry.route.verb))},
|
|
457
475
|
},
|
|
458
476
|
async (${argumentName}, context) => {
|
|
459
|
-
const ctx = deps.context(context, ${
|
|
477
|
+
const ctx = deps.context(context, ${literal(entry.route.authentication)});
|
|
460
478
|
if (ctx === null) return deps.noContext(context);
|
|
461
479
|
return toolResult(
|
|
462
480
|
${declared},
|
|
463
|
-
${
|
|
481
|
+
${literal(entry.route.operationId)},
|
|
464
482
|
await handlersFor(context).${entry.route.operationId}(ctx, ${argument}),
|
|
465
483
|
);
|
|
466
484
|
},
|
|
467
485
|
);
|
|
468
486
|
if (deps.permits(${renderSecurity(entry.requirements)})) {
|
|
469
|
-
registered.push(${
|
|
487
|
+
registered.push(${literal(entry.toolName)});
|
|
470
488
|
} else {
|
|
471
489
|
tool.remove();
|
|
472
490
|
}
|
|
@@ -531,7 +549,7 @@ export type ToolsExhaustive<T> = T & Record<Exclude<keyof T, DeclaredOperationId
|
|
|
531
549
|
export const SCOPES_SUPPORTED = ${scopesLiteral} as const;
|
|
532
550
|
|
|
533
551
|
/** Every tool this file registers, in the order it registers them. */
|
|
534
|
-
export const TOOL_NAMES = [${ordered.map((entry) =>
|
|
552
|
+
export const TOOL_NAMES = [${ordered.map((entry) => literal(entry.toolName)).join(", ")}] as const;
|
|
535
553
|
|
|
536
554
|
/**
|
|
537
555
|
* Register every tool this service declares, onto a server the APPLICATION owns.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "typespec-http-mcp",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "TypeSpec emitter: generate an MCP tool surface, and the Zod validators it publishes, from an HTTP service definition, dispatching into the same handlers a generated HTTP server calls.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"cloudflare-workers",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"provenance": true
|
|
46
46
|
},
|
|
47
47
|
"dependencies": {
|
|
48
|
-
"typespec-http-zod": "^0.
|
|
48
|
+
"typespec-http-zod": "^0.30.0"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@hono/zod-validator": "^0.9.1",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"oxfmt": "^0.67.0",
|
|
68
68
|
"oxlint": "^1.82.0",
|
|
69
69
|
"typescript": "~7.0.2",
|
|
70
|
-
"typespec-hono": "0.
|
|
70
|
+
"typespec-hono": "0.27.0",
|
|
71
71
|
"typespec-http-mcp": "link:.",
|
|
72
72
|
"vitest": "^5.0.0",
|
|
73
73
|
"wrangler": "^4.131.1",
|