@contractkit/plugin-typescript 0.28.1 → 0.29.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.
@@ -1,5 +1,7 @@
1
1
  import type { ContractTypeNode } from '@contractkit/core';
2
+ /** Declaration emitted into generated files that reference the `json` scalar. */
2
3
  export declare const JSON_VALUE_TYPE_DECL = "export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };";
4
+ /** Quote a property name unless it is already a valid bare TypeScript identifier. */
3
5
  export declare function quoteKey(name: string): string;
4
6
  /** Escape text for safe inclusion inside a JSDoc block comment: neutralize the
5
7
  * block-comment terminator sequence and split embedded newlines into separate
@@ -9,18 +11,34 @@ export declare function escapeJsDocLines(text: string): string[];
9
11
  export declare function escapeSingleQuoted(s: string): string;
10
12
  /** Convert an HTTP header name (e.g. `preference-applied`, `X-Request-ID`, `ETag`) to camelCase for use as a JS property. */
11
13
  export declare function headerNameToProperty(name: string): string;
12
- export declare function renderTsType(type: ContractTypeNode): string;
14
+ /**
15
+ * Which runtime the emitted types describe. A few scalars have no single correct TypeScript
16
+ * type: `binary` is a `Blob` in a fetch-based client but a `Buffer` on a Node server. Everything
17
+ * else renders identically for both targets. Defaults to `'client'`.
18
+ */
19
+ export type TsRenderTarget = 'client' | 'server';
20
+ /**
21
+ * Render a contract type as a plain TypeScript type expression. Model refs render as their bare
22
+ * name; use `renderInputTsType` / `renderOutputTsType` to substitute Input/Output variants.
23
+ *
24
+ * @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
25
+ */
26
+ export declare function renderTsType(type: ContractTypeNode, target?: TsRenderTarget): string;
13
27
  /**
14
28
  * Like renderTsType, but substitutes model refs with their Input variant
15
29
  * when the model has visibility modifiers. Used for request-side types
16
30
  * (body, params, query, headers).
31
+ *
32
+ * @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
17
33
  */
18
- export declare function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<string>): string;
34
+ export declare function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<string>, target?: TsRenderTarget): string;
19
35
  /**
20
36
  * Like renderTsType, but substitutes model refs with their Output variant
21
37
  * (post-transform wire shape) when the model has format(output=...) or
22
38
  * transitively references one. Used for response-side types in routers
23
39
  * and SDK return types.
40
+ *
41
+ * @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
24
42
  */
25
- export declare function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Set<string>): string;
43
+ export declare function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Set<string>, target?: TsRenderTarget): string;
26
44
  //# sourceMappingURL=ts-render.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,CAoC3D;AA+CD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2B/F;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2BjG"}
1
+ {"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,iFAAiF;AACjF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,qFAAqF;AACrF,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID;;;;GAIG;AACH,MAAM,MAAM,cAAc,GAAG,QAAQ,GAAG,QAAQ,CAAC;AAEjD;;;;;GAKG;AACH,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CAoC9F;AAgDD;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BlI;AAED;;;;;;;GAOG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,EAAE,MAAM,GAAE,cAAyB,GAAG,MAAM,CA2BpI"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@contractkit/plugin-typescript",
3
- "version": "0.28.1",
3
+ "version": "0.29.0",
4
4
  "description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
5
5
  "author": {
6
6
  "name": "Marooned Software",
@@ -22,6 +22,7 @@ import {
22
22
  collectExternalOutputRefs as ckCollectExternalOutputRefs,
23
23
  } from '@contractkit/core';
24
24
  import { escapeJsDocLines } from './ts-render.js';
25
+ import type { TsRenderTarget } from './ts-render.js';
25
26
 
26
27
  /**
27
28
  * Maps a ContractKit object mode to its Zod constructor name.
@@ -53,6 +54,12 @@ export interface ContractCodegenContext {
53
54
  modelsWithOutput?: Set<string>;
54
55
  /** If set, import JsonValue from this path instead of re-declaring it (avoids barrel re-export conflicts) */
55
56
  jsonValueImportPath?: string;
57
+ /**
58
+ * Runtime the emitted plain types describe. Only affects scalars whose TypeScript type differs
59
+ * per runtime (`binary` → `Buffer` on the server, `Blob` in the client). Ignored by
60
+ * `generateContract`, whose Zod schemas are server-shaped by construction. Default `'client'`.
61
+ */
62
+ target?: TsRenderTarget;
56
63
  }
57
64
 
58
65
  // ─── Public entry point ────────────────────────────────────────────────────
@@ -0,0 +1,501 @@
1
+ import type { OpRootNode, OpRouteNode, OpOperationNode, McpConfigNode, ParamSource, ContractTypeNode } from '@contractkit/core';
2
+ import { resolveModifiers } from '@contractkit/core';
3
+ import { renderType, renderInputType, pascalToDotCase } from './codegen-contract.js';
4
+ import { inferService, deriveModulePath, buildArgs, deriveBaseName } from './codegen-operation.js';
5
+ import { quoteKey, escapeSingleQuoted } from './ts-render.js';
6
+ import { basename, dirname, relative } from 'node:path';
7
+
8
+ // ─── Options ────────────────────────────────────────────────────────────────
9
+
10
+ export interface McpCodegenOptions {
11
+ /** Absolute path of the file being generated. Used to compute relative imports. */
12
+ outPath?: string;
13
+ /** Map from schema identifier (model name / `${name}Input`) → absolute output file. */
14
+ modelOutPaths?: Map<string, string>;
15
+ /** Model names that have an Input variant schema. */
16
+ modelsWithInput?: Set<string>;
17
+ /** Model names that have an Output variant schema. */
18
+ modelsWithOutput?: Set<string>;
19
+ /** Import-path template for service implementations (same semantics as ServerConfig). */
20
+ servicePathTemplate?: string;
21
+ /** Emit tools for `internal` operations. Default false. */
22
+ includeInternal?: boolean;
23
+ }
24
+
25
+ // ─── MCP flag helpers ─────────────────────────────────────────────────────
26
+
27
+ /** The explicit settings block, if the operation used the object form of `mcp:`. */
28
+ function mcpConfig(op: OpOperationNode): McpConfigNode | undefined {
29
+ return op.mcp && typeof op.mcp === 'object' ? op.mcp : undefined;
30
+ }
31
+
32
+ /**
33
+ * True if the root has at least one MCP-exposed operation. With `includeInternal: false`
34
+ * (default) `internal` operations don't count. Mirrors `hasPublicOperations`.
35
+ */
36
+ export function hasMcpOperations(root: OpRootNode, includeInternal = false): boolean {
37
+ for (const route of root.routes) {
38
+ for (const op of route.operations) {
39
+ if (!op.mcp) continue;
40
+ if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
41
+ return true;
42
+ }
43
+ }
44
+ return false;
45
+ }
46
+
47
+ // ─── Name derivation ────────────────────────────────────────────────────────
48
+
49
+ /** camelCase / PascalCase / spaced / hyphenated → snake_case. */
50
+ function toSnake(s: string): string {
51
+ return s
52
+ .replace(/([a-z0-9])([A-Z])/g, '$1_$2')
53
+ .replace(/[\s\-.]+/g, '_')
54
+ .toLowerCase()
55
+ .replace(/[^a-z0-9_]/g, '')
56
+ .replace(/_+/g, '_')
57
+ .replace(/^_|_$/g, '');
58
+ }
59
+
60
+ /** snake_case → PascalCase. */
61
+ function toPascal(s: string): string {
62
+ return s
63
+ .split('_')
64
+ .filter(Boolean)
65
+ .map(p => p.charAt(0).toUpperCase() + p.slice(1))
66
+ .join('');
67
+ }
68
+
69
+ /** Inferred snake_case tool name from method + path, e.g. GET /payments/{id} → get_payments_by_id. */
70
+ function inferToolName(method: string, path: string): string {
71
+ const parts: string[] = [method.toLowerCase()];
72
+ for (const seg of path.split('/').filter(Boolean)) {
73
+ if (seg.startsWith('{')) {
74
+ parts.push('by', toSnake(seg.slice(1, -1)));
75
+ } else {
76
+ parts.push(toSnake(seg));
77
+ }
78
+ }
79
+ return parts.filter(Boolean).join('_');
80
+ }
81
+
82
+ /** Tool name: explicit `mcp.name` (verbatim) → `sdk` → `name` → inferred; derived forms snake_cased. */
83
+ function deriveToolName(op: OpOperationNode, route: OpRouteNode): string {
84
+ const cfg = mcpConfig(op);
85
+ if (cfg?.name) return cfg.name;
86
+ if (op.sdk) return toSnake(op.sdk);
87
+ if (op.name) return toSnake(op.name);
88
+ return inferToolName(op.method, route.path);
89
+ }
90
+
91
+ /** Tool class name — PascalCase of the tool name with an `McpTool` suffix for clarity. */
92
+ function deriveToolClassName(toolName: string): string {
93
+ return `${toPascal(toolName)}McpTool`;
94
+ }
95
+
96
+ // ─── Input args schema ────────────────────────────────────────────────────
97
+
98
+ interface ArgsProp {
99
+ key: string;
100
+ expr: string;
101
+ optional: boolean;
102
+ }
103
+
104
+ /** Build the flat args properties for a tool, matching the router's `buildArgs` variable names. */
105
+ function buildArgsProps(route: OpRouteNode, op: OpOperationNode, modelsWithInput?: Set<string>): ArgsProp[] {
106
+ const props: ArgsProp[] = [];
107
+
108
+ // Path params — spread individually (inline) or as a single `params` object (ref/type).
109
+ if (route.params) {
110
+ if (route.params.kind === 'params') {
111
+ for (const node of route.params.nodes) {
112
+ props.push({ key: node.name, expr: renderInputType(node.type, modelsWithInput), optional: false });
113
+ }
114
+ } else if (route.params.kind === 'ref') {
115
+ props.push({ key: 'params', expr: refSchema(route.params.name, modelsWithInput), optional: false });
116
+ } else {
117
+ props.push({ key: 'params', expr: renderInputType(route.params.node, modelsWithInput), optional: false });
118
+ }
119
+ }
120
+
121
+ // Body — single JSON body maps to a `body` field; multipart/binary/multi bodies aren't cleanly
122
+ // representable as JSON tool args, so they fall back to `z.unknown()` (advisory only).
123
+ const bodies = op.request?.bodies ?? [];
124
+ if (bodies.length === 1 && bodies[0]!.contentType === 'multipart/form-data') {
125
+ props.push({ key: 'multipartBody', expr: 'z.unknown()', optional: false });
126
+ } else if (bodies.length === 1) {
127
+ props.push({ key: 'body', expr: renderInputType(bodies[0]!.bodyType, modelsWithInput), optional: false });
128
+ } else if (bodies.length > 1) {
129
+ props.push({ key: 'body', expr: 'z.unknown()', optional: false });
130
+ }
131
+
132
+ // Query / headers — whole objects, optional.
133
+ if (op.query) props.push({ key: 'query', expr: paramSourceSchema(op.query, modelsWithInput), optional: true });
134
+ if (op.headers) props.push({ key: 'headers', expr: paramSourceSchema(op.headers, modelsWithInput), optional: true });
135
+
136
+ return props;
137
+ }
138
+
139
+ function refSchema(name: string, modelsWithInput?: Set<string>): string {
140
+ return modelsWithInput?.has(name) ? `${name}Input` : name;
141
+ }
142
+
143
+ function paramSourceSchema(src: ParamSource, modelsWithInput?: Set<string>): string {
144
+ if (src.kind === 'ref') return refSchema(src.name, modelsWithInput);
145
+ if (src.kind === 'type') return renderInputType(src.node, modelsWithInput);
146
+ const fields = src.nodes.map(n => `${quoteKey(n.name)}: ${renderInputType(n.type, modelsWithInput)}`).join(', ');
147
+ return `z.object({ ${fields} })`;
148
+ }
149
+
150
+ function argsSchemaExpr(props: ArgsProp[]): string {
151
+ if (props.length === 0) return 'z.object({})';
152
+ const fields = props.map(p => `${quoteKey(p.key)}: ${p.expr}${p.optional ? '.optional()' : ''}`).join(', ');
153
+ return `z.object({ ${fields} })`;
154
+ }
155
+
156
+ // ─── Output schema ──────────────────────────────────────────────────────────
157
+
158
+ /** Primary response = first with a body, else the first response. */
159
+ function primaryResponse(op: OpOperationNode) {
160
+ return op.responses.find(r => r.bodyType) ?? op.responses[0];
161
+ }
162
+
163
+ /** MCP output schemas must be objects — only model refs and inline objects qualify. */
164
+ function outputSchemaExpr(op: OpOperationNode): string | undefined {
165
+ const body = primaryResponse(op)?.bodyType;
166
+ if (!body) return undefined;
167
+ if (body.kind === 'ref') return body.name;
168
+ if (body.kind === 'inlineObject') return renderType(body);
169
+ return undefined;
170
+ }
171
+
172
+ // ─── Annotations ────────────────────────────────────────────────────────────
173
+
174
+ const HINT_KEYS = ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as const;
175
+
176
+ function annotationsExpr(cfg: McpConfigNode | undefined): string | undefined {
177
+ if (!cfg) return undefined;
178
+ const parts: string[] = [];
179
+ for (const key of HINT_KEYS) {
180
+ const val = cfg[key];
181
+ if (val !== undefined) parts.push(`${key}: ${val}`);
182
+ }
183
+ return parts.length > 0 ? `{ ${parts.join(', ')} }` : undefined;
184
+ }
185
+
186
+ // ─── Schema-import collection ───────────────────────────────────────────────
187
+
188
+ function walkTypeRefs(type: ContractTypeNode, ids: Set<string>, variant: 'input' | 'read', modelsWithInput?: Set<string>): void {
189
+ switch (type.kind) {
190
+ case 'ref':
191
+ ids.add(variant === 'input' ? refSchema(type.name, modelsWithInput) : type.name);
192
+ break;
193
+ case 'array':
194
+ walkTypeRefs(type.item, ids, variant, modelsWithInput);
195
+ break;
196
+ case 'tuple':
197
+ type.items.forEach(t => walkTypeRefs(t, ids, variant, modelsWithInput));
198
+ break;
199
+ case 'record':
200
+ walkTypeRefs(type.key, ids, variant, modelsWithInput);
201
+ walkTypeRefs(type.value, ids, variant, modelsWithInput);
202
+ break;
203
+ case 'union':
204
+ case 'discriminatedUnion':
205
+ case 'intersection':
206
+ type.members.forEach(t => walkTypeRefs(t, ids, variant, modelsWithInput));
207
+ break;
208
+ case 'inlineObject':
209
+ type.fields.forEach(f => walkTypeRefs(f.type, ids, variant, modelsWithInput));
210
+ break;
211
+ case 'lazy':
212
+ walkTypeRefs(type.inner, ids, variant, modelsWithInput);
213
+ break;
214
+ }
215
+ }
216
+
217
+ function walkSourceRefs(src: ParamSource | undefined, ids: Set<string>, modelsWithInput?: Set<string>): void {
218
+ if (!src) return;
219
+ if (src.kind === 'ref') ids.add(refSchema(src.name, modelsWithInput));
220
+ else if (src.kind === 'params') src.nodes.forEach(n => walkTypeRefs(n.type, ids, 'input', modelsWithInput));
221
+ else walkTypeRefs(src.node, ids, 'input', modelsWithInput);
222
+ }
223
+
224
+ /** Collect every schema identifier the emitted tools import (input variants + read variants for output). */
225
+ function collectSchemaIds(ops: { route: OpRouteNode; op: OpOperationNode }[], modelsWithInput?: Set<string>): Set<string> {
226
+ const ids = new Set<string>();
227
+ for (const { route, op } of ops) {
228
+ walkSourceRefs(route.params, ids, modelsWithInput);
229
+ const bodies = op.request?.bodies ?? [];
230
+ if (bodies.length === 1 && bodies[0]!.contentType !== 'multipart/form-data') {
231
+ walkTypeRefs(bodies[0]!.bodyType, ids, 'input', modelsWithInput);
232
+ }
233
+ walkSourceRefs(op.query, ids, modelsWithInput);
234
+ walkSourceRefs(op.headers, ids, modelsWithInput);
235
+
236
+ const body = primaryResponse(op)?.bodyType;
237
+ if (body && (body.kind === 'ref' || body.kind === 'inlineObject')) walkTypeRefs(body, ids, 'read');
238
+ }
239
+ return ids;
240
+ }
241
+
242
+ function schemaImportLines(ids: Set<string>, options: McpCodegenOptions): string[] {
243
+ const lines: string[] = [];
244
+ const { modelOutPaths, outPath } = options;
245
+ if (ids.size === 0) return lines;
246
+ if (modelOutPaths && outPath) {
247
+ const byFile = new Map<string, string[]>();
248
+ const unresolved: string[] = [];
249
+ for (const id of ids) {
250
+ const p = modelOutPaths.get(id);
251
+ if (p) {
252
+ const group = byFile.get(p) ?? [];
253
+ group.push(id);
254
+ byFile.set(p, group);
255
+ } else {
256
+ unresolved.push(id);
257
+ }
258
+ }
259
+ const fromDir = dirname(outPath);
260
+ for (const [file, names] of byFile) {
261
+ let rel = relative(fromDir, file).replace(/\.ts$/, '.js');
262
+ if (!rel.startsWith('.')) rel = './' + rel;
263
+ lines.push(`import { ${names.sort().join(', ')} } from '${rel}';`);
264
+ }
265
+ for (const id of unresolved.sort()) lines.push(`import { ${id} } from './${pascalToDotCase(id)}.js';`);
266
+ } else {
267
+ for (const id of [...ids].sort()) lines.push(`import { ${id} } from './${pascalToDotCase(id)}.js';`);
268
+ }
269
+ return lines;
270
+ }
271
+
272
+ // ─── Zod scalar helper preludes ─────────────────────────────────────────────
273
+
274
+ function scalarHelperLines(body: string): string[] {
275
+ const lines: string[] = [];
276
+ if (body.includes('_ZodBinary')) {
277
+ lines.push(`const _ZodBinary = z.custom<Buffer>((val) => Buffer.isBuffer(val), { error: 'Must be binary data' });`);
278
+ }
279
+ if (body.includes('_ZodDatetime')) {
280
+ lines.push(
281
+ `const _ZodDatetime = z.preprocess((val) => typeof val === 'string' ? DateTime.fromISO(val) : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be in ISO 8601 format' }));`,
282
+ );
283
+ }
284
+ if (body.includes('_ZodInterval')) {
285
+ lines.push(
286
+ `const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
287
+ );
288
+ }
289
+ if (body.includes('_ZodJson')) {
290
+ lines.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
291
+ lines.push(
292
+ `const _ZodJson: z.ZodType<_JsonValue> = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(_ZodJson), z.record(z.string(), _ZodJson)]));`,
293
+ );
294
+ }
295
+ return lines;
296
+ }
297
+
298
+ // ─── Per-tool codegen ─────────────────────────────────────────────────────
299
+
300
+ interface ToolPlan {
301
+ route: OpRouteNode;
302
+ op: OpOperationNode;
303
+ toolName: string;
304
+ className: string;
305
+ argsConstName: string;
306
+ }
307
+
308
+ function planTools(root: OpRootNode, includeInternal: boolean): ToolPlan[] {
309
+ const plans: ToolPlan[] = [];
310
+ for (const route of root.routes) {
311
+ for (const op of route.operations) {
312
+ if (!op.mcp) continue;
313
+ if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
314
+ const toolName = deriveToolName(op, route);
315
+ const className = deriveToolClassName(toolName);
316
+ plans.push({ route, op, toolName, className, argsConstName: `${toPascal(toolName)}Args` });
317
+ }
318
+ }
319
+ return plans;
320
+ }
321
+
322
+ function renderToolClass(plan: ToolPlan, file: string, options: McpCodegenOptions): string[] {
323
+ const { route, op, toolName, className, argsConstName } = plan;
324
+ const cfg = mcpConfig(op);
325
+ const lines: string[] = [];
326
+
327
+ // JSDoc source link
328
+ const relFile = options.outPath ? relative(dirname(options.outPath), file) : file;
329
+ lines.push('/**');
330
+ lines.push(` * from [${basename(file)}](file://./${relFile}#L${op.loc.line})`);
331
+ lines.push(' */');
332
+
333
+ lines.push('@Injectable()');
334
+ lines.push(`export class ${className} implements McpToolHandler {`);
335
+
336
+ // definition
337
+ lines.push(' readonly definition: Tool = {');
338
+ lines.push(` name: '${escapeSingleQuoted(toolName)}',`);
339
+ if (cfg?.title) lines.push(` title: '${escapeSingleQuoted(cfg.title)}',`);
340
+ const desc = cfg?.description ?? op.description ?? route.description;
341
+ if (desc) lines.push(` description: '${escapeSingleQuoted(desc)}',`);
342
+ lines.push(` inputSchema: z.toJSONSchema(${argsConstName}, { unrepresentable: 'any' }) as Tool['inputSchema'],`);
343
+ const outExpr = outputSchemaExpr(op);
344
+ if (outExpr) lines.push(` outputSchema: z.toJSONSchema(${outExpr}, { unrepresentable: 'any' }) as Tool['outputSchema'],`);
345
+ const annotations = annotationsExpr(cfg);
346
+ if (annotations) lines.push(` annotations: ${annotations},`);
347
+ lines.push(' };');
348
+ lines.push('');
349
+
350
+ // constructor injects the operation's service
351
+ const service = inferService(op, route, file);
352
+ lines.push(` constructor(private readonly service: ${service.className}) {}`);
353
+ lines.push('');
354
+
355
+ // handle
356
+ const props = buildArgsProps(route, op, options.modelsWithInput);
357
+ const destructure = props.map(p => p.key);
358
+ const callArgs = buildArgs(route, op);
359
+ const isVoid = !primaryResponse(op)?.bodyType;
360
+ const structured = !!outExpr;
361
+
362
+ lines.push(' async handle(args: Record<string, unknown>, _context: McpToolContext): Promise<CallToolResult> {');
363
+ if (destructure.length > 0) {
364
+ lines.push(` const { ${destructure.join(', ')} } = await parseAndValidate(args, ${argsConstName});`);
365
+ }
366
+ if (isVoid) {
367
+ lines.push(` await this.service.${service.methodName}(${callArgs});`);
368
+ lines.push(` return { content: [{ type: 'text', text: 'OK' }] };`);
369
+ } else {
370
+ lines.push(` const result = await this.service.${service.methodName}(${callArgs});`);
371
+ if (structured) {
372
+ lines.push(` return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };`);
373
+ } else {
374
+ lines.push(` return { content: [{ type: 'text', text: JSON.stringify(result) }] };`);
375
+ }
376
+ }
377
+ lines.push(' }');
378
+ lines.push('}');
379
+
380
+ return lines;
381
+ }
382
+
383
+ // ─── Public entry points ────────────────────────────────────────────────────
384
+
385
+ /** The exported register-fn name for an op-root file, e.g. `payments.op` → `registerPaymentsMcpTools`. */
386
+ export function deriveMcpRegisterFnName(file: string): string {
387
+ return `register${deriveBaseName(file)}McpTools`;
388
+ }
389
+
390
+ /** Generate one `<filename>.mcp.ts` for an op-root: tool handler classes + a per-file register fn. */
391
+ export function generateMcpFile(root: OpRootNode, options: McpCodegenOptions = {}): string {
392
+ const includeInternal = options.includeInternal ?? false;
393
+ const plans = planTools(root, includeInternal);
394
+
395
+ // Args schema consts (also drive the JSON-Schema definitions).
396
+ const argsConsts = plans.map(p => `const ${p.argsConstName} = ${argsSchemaExpr(buildArgsProps(p.route, p.op, options.modelsWithInput))};`);
397
+
398
+ // Tool classes.
399
+ const classes = plans.map(p => renderToolClass(p, root.file, options).join('\n'));
400
+
401
+ // Per-file register fn.
402
+ const registerFn: string[] = [];
403
+ registerFn.push(`/** Add this file's tools to the shared catalog. */`);
404
+ registerFn.push(`export function ${deriveMcpRegisterFnName(root.file)}(map: McpToolHandlerMap, container: Container): void {`);
405
+ for (const p of plans) registerFn.push(` map.set('${escapeSingleQuoted(p.toolName)}', container.get(${p.className}));`);
406
+ registerFn.push('}');
407
+
408
+ const bodyCore = [argsConsts.join('\n'), classes.join('\n\n'), registerFn.join('\n')].filter(Boolean).join('\n\n');
409
+
410
+ // Zod scalar helper consts (must precede the args consts that reference them).
411
+ const helperConsts = scalarHelperLines(bodyCore);
412
+ const bodyWithHelpers = [helperConsts.join('\n'), bodyCore].filter(Boolean).join('\n\n');
413
+
414
+ // ── Imports ──
415
+ const needsParseAndValidate = plans.some(p => buildArgsProps(p.route, p.op, options.modelsWithInput).length > 0);
416
+ const imports: string[] = [];
417
+ imports.push(`import { Injectable, type Container } from 'injectkit';`);
418
+ imports.push(`import { z } from 'zod';`);
419
+
420
+ const luxon: string[] = [];
421
+ if (/\bDateTime\b/.test(bodyWithHelpers)) luxon.push('DateTime');
422
+ if (/\bInterval\b/.test(bodyWithHelpers)) luxon.push('Interval');
423
+ if (/\bDuration\b/.test(bodyWithHelpers)) luxon.push('Duration');
424
+ if (luxon.length > 0) imports.push(`import { ${luxon.join(', ')} } from 'luxon';`);
425
+
426
+ imports.push(`import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';`);
427
+ imports.push(`import type { McpToolHandler, McpToolHandlerMap, McpToolContext } from '@maroonedsoftware/mcp';`);
428
+ if (needsParseAndValidate) imports.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
429
+
430
+ // Service imports (one per distinct service used by the emitted tools).
431
+ const serviceModules = new Map<string, string>();
432
+ for (const p of plans) {
433
+ const svc = inferService(p.op, p.route, root.file).className;
434
+ if (!serviceModules.has(svc)) {
435
+ serviceModules.set(svc, root.services?.[svc] ?? root.meta[svc] ?? deriveModulePath(svc, options.servicePathTemplate));
436
+ }
437
+ }
438
+ for (const [svc, mod] of [...serviceModules.entries()].sort(([a], [b]) => a.localeCompare(b))) {
439
+ imports.push(`import { ${svc} } from '${mod}';`);
440
+ }
441
+
442
+ // Schema imports.
443
+ imports.push(...schemaImportLines(collectSchemaIds(plans, options.modelsWithInput), options));
444
+
445
+ const relFile = options.outPath ? relative(dirname(options.outPath), root.file) : root.file;
446
+ const header = `// Auto-generated MCP tools\n// generated from [${basename(root.file)}](file://./${relFile})`;
447
+
448
+ return `${header}\n${imports.join('\n')}\n\n${bodyWithHelpers}\n`;
449
+ }
450
+
451
+ /** One entry per emitted `<filename>.mcp.ts` for the aggregator to import. */
452
+ export interface McpAggregatorEntry {
453
+ /** The file's exported register fn name, e.g. `registerPaymentsMcpTools`. */
454
+ registerFn: string;
455
+ /** Module specifier for the file, relative to the aggregator and `.js`-suffixed. */
456
+ importPath: string;
457
+ }
458
+
459
+ /** Generate the aggregator `mcp.tools.ts` that assembles the single McpToolHandlerMap. */
460
+ export function generateMcpAggregator(entries: McpAggregatorEntry[]): string {
461
+ const sorted = [...entries].sort((a, b) => a.registerFn.localeCompare(b.registerFn));
462
+ const lines: string[] = [];
463
+ lines.push(`import { type Container } from 'injectkit';`);
464
+ lines.push(`import { McpToolHandlerMap } from '@maroonedsoftware/mcp';`);
465
+ for (const e of sorted) lines.push(`import { ${e.registerFn} } from '${e.importPath}';`);
466
+ lines.push('');
467
+ lines.push('/** Build + register the MCP tool catalog. Call once at startup. */');
468
+ lines.push('export function registerMcpTools(container: Container): McpToolHandlerMap {');
469
+ lines.push(' const map = new McpToolHandlerMap();');
470
+ for (const e of sorted) lines.push(` ${e.registerFn}(map, container);`);
471
+ lines.push(' container.register(McpToolHandlerMap, { useValue: map });');
472
+ lines.push(' return map;');
473
+ lines.push('}');
474
+ return lines.join('\n') + '\n';
475
+ }
476
+
477
+ /** Generate the optional `mcp.router.ts` — the standard ServerKit route wiring for the dispatcher. */
478
+ export function generateMcpRouter(options: { path?: string } = {}): string {
479
+ const path = options.path ?? '/mcp';
480
+ return `import { ServerKitRouter, requireSignature } from '@maroonedsoftware/koa';
481
+ import { McpDispatcher, createMcpRequestContext, MCP_AUTH_POLICY } from '@maroonedsoftware/mcp';
482
+
483
+ /** Mount the MCP endpoint onto a ServerKit router. Call \`registerMcpTools(container)\` at startup. */
484
+ export function mountMcp(router: ReturnType<typeof ServerKitRouter>): void {
485
+ router.post('${path}', requireSignature('mcp', { policy: MCP_AUTH_POLICY }), async (ctx) => {
486
+ const dispatcher = ctx.container.get(McpDispatcher);
487
+ const context = createMcpRequestContext({ requestId: ctx.requestId, logger: ctx.logger });
488
+ if (dispatcher.sessionMode === 'stateful') {
489
+ ctx.respond = false;
490
+ await dispatcher.dispatchStateful(
491
+ { req: ctx.req, res: ctx.res, body: ctx.request.body, sessionId: ctx.get('mcp-session-id') },
492
+ context,
493
+ );
494
+ } else {
495
+ const response = await dispatcher.dispatch(JSON.parse(ctx.rawBody), context);
496
+ if (response) ctx.body = response;
497
+ }
498
+ });
499
+ }
500
+ `;
501
+ }