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 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: {
@@ -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
  } | {
@@ -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
- const body = operation.parameters.body?.type;
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]) => `${JSON.stringify(scheme)}: [${scopes.map((s) => JSON.stringify(s)).join(", ")}]`)
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)}: ${JSON.stringify(selected.value)}`;
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 = JSON.stringify(DEFAULT_RUNTIME_MODULE);
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) => JSON.stringify(scope)).join(", ")}]`;
417
+ const scopesLiteral = `[${scopes.map((scope) => literal(scope)).join(", ")}]`;
400
418
  const declaredUnion = declaredOperationIds.length === 0
401
419
  ? "never"
402
- : declaredOperationIds.map((id) => JSON.stringify(id)).join(" | ");
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: ${JSON.stringify(entry.title)},`;
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: ${JSON.stringify(entry.description)},`;
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
- `${JSON.stringify(mediaType)}: ${JSON.stringify(kind)}`,
445
+ `${literal(mediaType)}: ${literal(kind)}`,
428
446
  ]);
429
447
  }
430
448
  const bodies = kinds.size === 0
431
449
  ? ""
432
- : `, bodies: { ${[...kinds].map(([status, byType]) => `${JSON.stringify(status)}: { ${byType.join(", ")} }`).join(", ")} }`;
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
- ${JSON.stringify(entry.toolName)},
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, ${JSON.stringify(entry.route.authentication)});
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
- ${JSON.stringify(entry.route.operationId)},
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(${JSON.stringify(entry.toolName)});
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) => JSON.stringify(entry.toolName)).join(", ")}] as const;
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.2.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.28.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.25.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",