@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,4 +1,4 @@
1
- import type { OpRootNode, OpRouteNode, OpOperationNode, ContractTypeNode, ParamSource, ObjectMode } from '@contractkit/core';
1
+ import type { OpRootNode, OpRouteNode, OpOperationNode, ContractTypeNode, ScalarTypeNode, ParamSource, ObjectMode } from '@contractkit/core';
2
2
  import { resolveModifiers, resolveSecurity, SECURITY_NONE, classifyContentType } from '@contractkit/core';
3
3
  import {
4
4
  renderType,
@@ -107,6 +107,7 @@ export function bodyTypesStructurallyEqual(a: ContractTypeNode, b: ContractTypeN
107
107
 
108
108
  // ─── Public entry point ────────────────────────────────────────────────────
109
109
 
110
+ /** Options controlling how {@link generateOp} renders a Koa router module. */
110
111
  export interface OpCodegenOptions {
111
112
  servicePathTemplate?: string;
112
113
  typeImportPathTemplate?: string;
@@ -152,8 +153,14 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
152
153
  body.push(...generateTypeImports(types, root.file, options));
153
154
  }
154
155
 
155
- if (opNeedsDateTime(root)) {
156
- body.push(`import { DateTime } from 'luxon';`);
156
+ // luxon is needed for date/time/datetime (DateTime), duration (Duration) and interval (Interval);
157
+ // the rendered Zod schemas and the service-result annotations both reference these classes.
158
+ const luxonImports: string[] = [];
159
+ if (opNeedsDateTime(root)) luxonImports.push('DateTime');
160
+ if (opNeedsScalar(root, 'duration')) luxonImports.push('Duration');
161
+ if (opNeedsScalar(root, 'interval')) luxonImports.push('Interval');
162
+ if (luxonImports.length > 0) {
163
+ body.push(`import { ${luxonImports.join(', ')} } from 'luxon';`);
157
164
  }
158
165
 
159
166
  if (needsParseAndValidate) {
@@ -173,6 +180,11 @@ export function generateOp(root: OpRootNode, options: OpCodegenOptions = {}): st
173
180
  `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' }));`,
174
181
  );
175
182
  }
183
+ if (opNeedsScalar(root, 'interval')) {
184
+ helpers.push(
185
+ `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()!);`,
186
+ );
187
+ }
176
188
  if (opNeedsScalar(root, 'json')) {
177
189
  helpers.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
178
190
  helpers.push(
@@ -267,7 +279,7 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
267
279
  }
268
280
  const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
269
281
 
270
- lines.push(`${deriveRouterName(file)}.${method}('${path}'${middlewareStr} async (ctx, next) => {`);
282
+ lines.push(`${deriveRouterName(file)}.${method}('${path}'${middlewareStr} async ctx => {`);
271
283
 
272
284
  // Params / query / headers validation (request-side — use Input variants)
273
285
  lines.push(...generateParamValidation(route.params, 'ctx.params', 'params', route.paramsMode ?? 'strict', '', modelsWithInput));
@@ -316,7 +328,10 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
316
328
  const hasRespHeaders = respHeaders.length > 0;
317
329
  const headersAnnotation = hasRespHeaders
318
330
  ? `{ ${respHeaders
319
- .map(h => `${quoteKey(headerNameToProperty(h.name))}${h.optional ? '?' : ''}: ${renderOutputTsType(h.type, options.modelsWithOutput)}`)
331
+ .map(
332
+ h =>
333
+ `${quoteKey(headerNameToProperty(h.name))}${h.optional ? '?' : ''}: ${renderOutputTsType(h.type, options.modelsWithOutput, 'server')}`,
334
+ )
320
335
  .join('; ')} }`
321
336
  : '';
322
337
 
@@ -368,7 +383,16 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
368
383
 
369
384
  // ─── Inference helpers ─────────────────────────────────────────────────────
370
385
 
371
- function inferService(op: OpOperationNode, route: OpRouteNode, file: string): { className: string; methodName: string } {
386
+ /**
387
+ * Resolve the service class and method a handler should delegate to.
388
+ *
389
+ * Uses the operation's explicit `service: Class.method` declaration when present; otherwise derives
390
+ * the class from the contract file name (`ledger.categories.ck` → `LedgerCategoriesService`) and the
391
+ * method from the HTTP verb and whether the path carries a parameter (`get` → `list` / `getById`).
392
+ *
393
+ * @param file Path of the `.ck` file the operation came from.
394
+ */
395
+ export function inferService(op: OpOperationNode, route: OpRouteNode, file: string): { className: string; methodName: string } {
372
396
  // If explicitly declared: service: ServiceClass.methodName
373
397
  if (op.service) {
374
398
  const [cls = '', method] = op.service.split('.');
@@ -400,7 +424,16 @@ function inferMethodName(method: string, path: string): string {
400
424
  }
401
425
  }
402
426
 
403
- function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
427
+ /**
428
+ * Build the comma-separated argument list passed to the service method in a generated handler.
429
+ *
430
+ * Order is params, body, query, headers. Inline path params are spread as individual identifiers;
431
+ * a referenced/compound params type is passed as a single `params` object. A lone
432
+ * `multipart/form-data` request body is passed as `multipartBody` rather than `body`.
433
+ *
434
+ * @returns The rendered argument list, or an empty string when the method takes no arguments.
435
+ */
436
+ export function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
404
437
  const args: string[] = [];
405
438
  // Path params: spread individually (inline) or pass 'params' object (type-ref/ContractTypeNode)
406
439
  if (route.params) {
@@ -423,6 +456,52 @@ function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
423
456
  return args.join(', ');
424
457
  }
425
458
 
459
+ /**
460
+ * Map a `.ck` scalar to the TypeScript type a server handler sees, i.e. `z.infer` of the schema
461
+ * `renderType` emits for that scalar. This is deliberately NOT `renderTsScalar` from ts-render:
462
+ * that one describes the wire/SDK view (`binary` → `Blob`, dates → `string`), while the router
463
+ * runs on Node against the parsed Zod output (`binary` → `Buffer`, dates → luxon `DateTime`).
464
+ */
465
+ function serverTsScalar(name: ScalarTypeNode['name']): string {
466
+ switch (name) {
467
+ case 'string':
468
+ case 'email':
469
+ case 'url':
470
+ case 'uuid':
471
+ return 'string';
472
+ case 'number':
473
+ case 'int':
474
+ return 'number';
475
+ case 'bigint':
476
+ return 'bigint';
477
+ case 'boolean':
478
+ return 'boolean';
479
+ case 'date':
480
+ case 'time':
481
+ case 'datetime':
482
+ return 'DateTime';
483
+ case 'duration':
484
+ return 'Duration';
485
+ case 'interval':
486
+ // _ZodInterval transforms to an ISO string, so the inferred output type is string.
487
+ return 'string';
488
+ case 'binary':
489
+ return 'Buffer';
490
+ case 'json':
491
+ return '_JsonValue';
492
+ case 'object':
493
+ return 'Record<string, unknown>';
494
+ case 'null':
495
+ return 'null';
496
+ case 'unknown':
497
+ return 'unknown';
498
+ default: {
499
+ const _exhaustive: never = name;
500
+ throw new Error(`plugin-typescript: unmapped scalar '${String(_exhaustive)}' — add a case`);
501
+ }
502
+ }
503
+ }
504
+
426
505
  function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set<string>): { annotation: string; prelude?: string } {
427
506
  if (bodyType.kind === 'array') {
428
507
  const inner = formatTypeAnnotation(bodyType.item, modelsWithOutput);
@@ -432,7 +511,7 @@ function formatTypeAnnotation(bodyType: ContractTypeNode, modelsWithOutput?: Set
432
511
  const name = modelsWithOutput?.has(bodyType.name) ? `${bodyType.name}Output` : bodyType.name;
433
512
  return { annotation: name };
434
513
  }
435
- if (bodyType.kind === 'scalar') return { annotation: bodyType.name };
514
+ if (bodyType.kind === 'scalar') return { annotation: serverTsScalar(bodyType.name) };
436
515
  // For complex types, extract schema into a variable so the result line stays readable
437
516
  const schema = renderType(bodyType);
438
517
  return {
@@ -792,7 +871,13 @@ function isValidIdentifier(name: string): boolean {
792
871
 
793
872
  // ─── Naming conventions ────────────────────────────────────────────────────
794
873
 
795
- function deriveBaseName(file: string): string {
874
+ /**
875
+ * Derive the PascalCase base name used for router, service, and type names from a contract file path.
876
+ *
877
+ * Strips directories and the `.op`/`.ck` extension, then PascalCases each dot-separated segment
878
+ * (`contracts/ledger.categories.ck` → `LedgerCategories`). Falls back to `Resource` for an empty path.
879
+ */
880
+ export function deriveBaseName(file: string): string {
796
881
  const base =
797
882
  file
798
883
  .split('/')
@@ -809,7 +894,16 @@ function deriveRouterName(file: string): string {
809
894
  return `${deriveBaseName(file)}Router`;
810
895
  }
811
896
 
812
- function deriveModulePath(serviceName: string, template?: string): string {
897
+ /**
898
+ * Resolve the import specifier for a service class.
899
+ *
900
+ * Drops the trailing `Service` suffix and kebab-cases the remainder, then applies `template` if given
901
+ * (`{name}` → `Ledger`, `{kebab}` → `ledger`). Without a template, defaults to
902
+ * `#modules/<kebab>/<kebab>.service.js`.
903
+ *
904
+ * @param template Optional `servicePathTemplate` from the plugin config.
905
+ */
906
+ export function deriveModulePath(serviceName: string, template?: string): string {
813
907
  // LedgerService -> #modules/ledger/ledger.service.js
814
908
  const base = serviceName.replace(/Service$/, '');
815
909
  const kebab = base.replace(/([A-Z])/g, m => `-${m.toLowerCase()}`).replace(/^-/, '');
@@ -11,6 +11,7 @@ import {
11
11
  rootNeedsScalar,
12
12
  } from './codegen-contract.js';
13
13
  import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJsDocLines, JSON_VALUE_TYPE_DECL } from './ts-render.js';
14
+ import type { TsRenderTarget } from './ts-render.js';
14
15
 
15
16
  // ─── Public entry point ────────────────────────────────────────────────────
16
17
 
@@ -19,8 +20,12 @@ import { renderTsType, renderInputTsType, renderOutputTsType, quoteKey, escapeJs
19
20
  * Unlike `generateContract()` which produces Zod schemas, this emits
20
21
  * vanilla TypeScript `interface` and `type` declarations suitable
21
22
  * for SDK consumers that don't need runtime validation.
23
+ *
24
+ * @param context Import resolution and Input/Output variant sets. `context.target` selects the
25
+ * runtime the types describe (`'server'` renders `binary` as `Buffer`, `'client'` as `Blob`).
22
26
  */
23
27
  export function generatePlainTypes(root: ContractRootNode, context?: ContractCodegenContext): string {
28
+ const target: TsRenderTarget = context?.target ?? 'client';
24
29
  const externalRefs = collectExternalRefs(root);
25
30
  const lines: string[] = [];
26
31
 
@@ -58,7 +63,7 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
58
63
  const modelMap = new Map(root.models.map(m => [m.name, m]));
59
64
 
60
65
  for (const model of topoSortModels(root.models)) {
61
- lines.push(...generateModel(model, context?.currentOutPath, allModelsWithInput, allModelsWithOutput, modelMap));
66
+ lines.push(...generateModel(model, target, context?.currentOutPath, allModelsWithInput, allModelsWithOutput, modelMap));
62
67
  lines.push('');
63
68
  }
64
69
 
@@ -69,6 +74,7 @@ export function generatePlainTypes(root: ContractRootNode, context?: ContractCod
69
74
 
70
75
  function generateModel(
71
76
  model: ModelNode,
77
+ target: TsRenderTarget,
72
78
  outPath?: string,
73
79
  modelsWithInput?: Set<string>,
74
80
  modelsWithOutput?: Set<string>,
@@ -76,18 +82,20 @@ function generateModel(
76
82
  ): string[] {
77
83
  // Type alias: Name : typeExpression
78
84
  if (model.type) {
79
- return generateTypeAlias(model, outPath, modelsWithInput, modelsWithOutput);
85
+ return generateTypeAlias(model, target, outPath, modelsWithInput, modelsWithOutput);
80
86
  }
81
87
 
82
88
  // A model needs Input/read split if it has visibility-modified fields OR if it
83
89
  // transitively references models that have Input variants (captured in modelsWithInput).
84
90
  const needsInputSplit = model.fields.some(f => f.visibility !== 'normal') || (modelsWithInput?.has(model.name) ?? false);
85
91
 
86
- const lines = needsInputSplit ? generateVisibilityModel(model, outPath, modelsWithInput, modelMap) : generateSimpleModel(model, outPath, modelMap);
92
+ const lines = needsInputSplit
93
+ ? generateVisibilityModel(model, target, outPath, modelsWithInput, modelMap)
94
+ : generateSimpleModel(model, target, outPath, modelMap);
87
95
 
88
96
  if (modelsWithOutput?.has(model.name)) {
89
97
  lines.push('');
90
- lines.push(...generateOutputModel(model, modelsWithOutput));
98
+ lines.push(...generateOutputModel(model, target, modelsWithOutput));
91
99
  }
92
100
  return lines;
93
101
  }
@@ -132,15 +140,21 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
132
140
  return lines;
133
141
  }
134
142
 
135
- function generateTypeAlias(model: ModelNode, outPath?: string, modelsWithInput?: Set<string>, modelsWithOutput?: Set<string>): string[] {
143
+ function generateTypeAlias(
144
+ model: ModelNode,
145
+ target: TsRenderTarget,
146
+ outPath?: string,
147
+ modelsWithInput?: Set<string>,
148
+ modelsWithOutput?: Set<string>,
149
+ ): string[] {
136
150
  const lines: string[] = [];
137
151
  lines.push(...generateComments(model, outPath));
138
- lines.push(`export type ${model.name} = ${renderTsType(model.type!)};`);
152
+ lines.push(`export type ${model.name} = ${renderTsType(model.type!, target)};`);
139
153
  if (modelsWithInput?.has(model.name)) {
140
- lines.push(`export type ${model.name}Input = ${renderInputTsType(model.type!, modelsWithInput)};`);
154
+ lines.push(`export type ${model.name}Input = ${renderInputTsType(model.type!, modelsWithInput, target)};`);
141
155
  }
142
156
  if (modelsWithOutput?.has(model.name)) {
143
- lines.push(`export type ${model.name}Output = ${renderOutputTsType(model.type!, modelsWithOutput)};`);
157
+ lines.push(`export type ${model.name}Output = ${renderOutputTsType(model.type!, modelsWithOutput, target)};`);
144
158
  }
145
159
  return lines;
146
160
  }
@@ -158,7 +172,7 @@ function buildExtendsClause(bases: string[], overrideNames: string[], baseNameRe
158
172
  return ` extends ${wrapped.join(', ')}`;
159
173
  }
160
174
 
161
- function generateSimpleModel(model: ModelNode, outPath?: string, modelMap?: Map<string, ModelNode>): string[] {
175
+ function generateSimpleModel(model: ModelNode, target: TsRenderTarget, outPath?: string, modelMap?: Map<string, ModelNode>): string[] {
162
176
  const lines: string[] = [];
163
177
  lines.push(...generateComments(model, outPath));
164
178
 
@@ -167,14 +181,20 @@ function generateSimpleModel(model: ModelNode, outPath?: string, modelMap?: Map<
167
181
  lines.push(`export interface ${model.name}${buildExtendsClause(bases, overrideNames, b => b)} {`);
168
182
 
169
183
  for (const field of model.fields) {
170
- lines.push(` ${renderField(field)}`);
184
+ lines.push(` ${renderField(field, target)}`);
171
185
  }
172
186
 
173
187
  lines.push('}');
174
188
  return lines;
175
189
  }
176
190
 
177
- function generateVisibilityModel(model: ModelNode, outPath?: string, modelsWithInput?: Set<string>, modelMap?: Map<string, ModelNode>): string[] {
191
+ function generateVisibilityModel(
192
+ model: ModelNode,
193
+ target: TsRenderTarget,
194
+ outPath?: string,
195
+ modelsWithInput?: Set<string>,
196
+ modelMap?: Map<string, ModelNode>,
197
+ ): string[] {
178
198
  const lines: string[] = [];
179
199
  lines.push(...generateComments(model, outPath));
180
200
 
@@ -185,7 +205,7 @@ function generateVisibilityModel(model: ModelNode, outPath?: string, modelsWithI
185
205
  const readFields = model.fields.filter(f => f.visibility !== 'writeonly');
186
206
  lines.push(`export interface ${model.name}${buildExtendsClause(bases, overrideNames, b => b)} {`);
187
207
  for (const field of readFields) {
188
- lines.push(` ${renderField(field)}`);
208
+ lines.push(` ${renderField(field, target)}`);
189
209
  }
190
210
  lines.push('}');
191
211
  lines.push('');
@@ -196,7 +216,7 @@ function generateVisibilityModel(model: ModelNode, outPath?: string, modelsWithI
196
216
  const inputResolver = (b: string) => (modelsWithInput?.has(b) ? `${b}Input` : b);
197
217
  lines.push(`export interface ${model.name}Input${buildExtendsClause(bases, overrideNames, inputResolver)} {`);
198
218
  for (const field of writeFields) {
199
- lines.push(` ${modelsWithInput ? renderInputField(field, modelsWithInput) : renderField(field)}`);
219
+ lines.push(` ${modelsWithInput ? renderInputField(field, modelsWithInput, target) : renderField(field, target)}`);
200
220
  }
201
221
  lines.push('}');
202
222
 
@@ -217,9 +237,9 @@ function withFieldJsDoc(jsdocParts: string[], line: string): string {
217
237
  return `/**\n${body}\n */\n ${line}`;
218
238
  }
219
239
 
220
- function renderField(field: FieldNode): string {
240
+ function renderField(field: FieldNode, target: TsRenderTarget): string {
221
241
  const opt = field.optional || field.default !== undefined ? '?' : '';
222
- let typeStr = renderTsType(field.type);
242
+ let typeStr = renderTsType(field.type, target);
223
243
  if (field.nullable) typeStr += ' | null';
224
244
  const line = `${quoteKey(field.name)}${opt}: ${typeStr};`;
225
245
  const jsdocParts: string[] = [];
@@ -228,9 +248,9 @@ function renderField(field: FieldNode): string {
228
248
  return withFieldJsDoc(jsdocParts, line);
229
249
  }
230
250
 
231
- function renderInputField(field: FieldNode, modelsWithInput: Set<string>): string {
251
+ function renderInputField(field: FieldNode, modelsWithInput: Set<string>, target: TsRenderTarget): string {
232
252
  const opt = field.optional || field.default !== undefined ? '?' : '';
233
- let typeStr = renderInputTsType(field.type, modelsWithInput);
253
+ let typeStr = renderInputTsType(field.type, modelsWithInput, target);
234
254
  if (field.nullable) typeStr += ' | null';
235
255
  const line = `${quoteKey(field.name)}${opt}: ${typeStr};`;
236
256
  const jsdocParts: string[] = [];
@@ -264,7 +284,7 @@ function applyOutputCase(name: string, c: 'camel' | 'snake' | 'pascal' | undefin
264
284
  * ancestor has format(...) (see `flattenFormatChain` in codegen-contract); we mirror that here
265
285
  * so the plain interface matches the wire shape produced by the Zod transform.
266
286
  */
267
- function generateOutputModel(model: ModelNode, modelsWithOutput: Set<string>): string[] {
287
+ function generateOutputModel(model: ModelNode, target: TsRenderTarget, modelsWithOutput: Set<string>): string[] {
268
288
  const lines: string[] = [];
269
289
  const outputCase = model.outputCase && model.outputCase !== 'camel' ? model.outputCase : undefined;
270
290
  const readFields = model.fields.filter(f => f.visibility !== 'writeonly');
@@ -279,7 +299,7 @@ function generateOutputModel(model: ModelNode, modelsWithOutput: Set<string>): s
279
299
  : '';
280
300
  lines.push(`export interface ${model.name}Output${baseExt} {`);
281
301
  for (const field of readFields) {
282
- lines.push(` ${renderOutputField(field, model.outputCase, modelsWithOutput)}`);
302
+ lines.push(` ${renderOutputField(field, model.outputCase, modelsWithOutput, target)}`);
283
303
  }
284
304
  lines.push('}');
285
305
  return lines;
@@ -288,16 +308,21 @@ function generateOutputModel(model: ModelNode, modelsWithOutput: Set<string>): s
288
308
  // Direct hit: emit a flat interface with renamed keys.
289
309
  lines.push(`export interface ${model.name}Output {`);
290
310
  for (const field of readFields) {
291
- lines.push(` ${renderOutputField(field, outputCase, modelsWithOutput)}`);
311
+ lines.push(` ${renderOutputField(field, outputCase, modelsWithOutput, target)}`);
292
312
  }
293
313
  lines.push('}');
294
314
  return lines;
295
315
  }
296
316
 
297
- function renderOutputField(field: FieldNode, outputCase: 'camel' | 'snake' | 'pascal' | undefined, modelsWithOutput: Set<string>): string {
317
+ function renderOutputField(
318
+ field: FieldNode,
319
+ outputCase: 'camel' | 'snake' | 'pascal' | undefined,
320
+ modelsWithOutput: Set<string>,
321
+ target: TsRenderTarget,
322
+ ): string {
298
323
  const opt = field.optional || field.default !== undefined ? '?' : '';
299
324
  const key = applyOutputCase(field.name, outputCase);
300
- let typeStr = renderOutputTsType(field.type, modelsWithOutput);
325
+ let typeStr = renderOutputTsType(field.type, modelsWithOutput, target);
301
326
  if (field.nullable) typeStr += ' | null';
302
327
  const line = `${quoteKey(key)}${opt}: ${typeStr};`;
303
328
  const jsdocParts: string[] = [];
package/src/index.ts CHANGED
@@ -41,6 +41,7 @@ import {
41
41
  type SdkScaffoldDeps,
42
42
  } from './codegen-sdk.js';
43
43
  import { generatePlainTypes } from './codegen-plain-types.js';
44
+ import { generateMcpFile, generateMcpAggregator, generateMcpRouter, hasMcpOperations, deriveMcpRegisterFnName } from './codegen-mcp.js';
44
45
  import {
45
46
  TEMPLATE_VAR_RE,
46
47
  resolveTemplate,
@@ -56,6 +57,7 @@ import {
56
57
 
57
58
  // ─── Sub-config interfaces ─────────────────────────────────────────────────
58
59
 
60
+ /** Koa server output: routers, and the type or Zod schema files they import. */
59
61
  export interface ServerConfig {
60
62
  /** Directory (relative to rootDir) where server files are written. Default: rootDir. */
61
63
  baseDir?: string;
@@ -73,6 +75,7 @@ export interface ServerConfig {
73
75
  includeInternal?: boolean;
74
76
  }
75
77
 
78
+ /** TypeScript SDK client output: the client class, per-area operation clients, and their types. */
76
79
  export interface SdkConfig {
77
80
  baseDir?: string;
78
81
  name?: string;
@@ -94,21 +97,59 @@ export interface SdkConfig {
94
97
  scaffold?: boolean;
95
98
  }
96
99
 
100
+ /** Standalone Zod schema output, independent of the server and SDK sub-generators. */
97
101
  export interface ZodConfig {
98
102
  baseDir?: string;
99
103
  output?: string;
100
104
  }
101
105
 
106
+ /** Standalone plain TypeScript type output, independent of the server and SDK sub-generators. */
102
107
  export interface TypesConfig {
103
108
  baseDir?: string;
104
109
  output?: string;
110
+ /**
111
+ * Runtime the emitted types describe. Affects scalars whose TypeScript type is runtime-specific:
112
+ * `binary` renders as `Buffer` for `'server'` and `Blob` for `'client'`. Default `'client'`.
113
+ * The `server` and `sdk` sub-generators set this themselves.
114
+ */
115
+ target?: 'client' | 'server';
116
+ }
117
+
118
+ /** MCP tool output: per-op-file handlers, the aggregator, and the optional POST route. */
119
+ export interface McpConfig {
120
+ /** Directory (relative to rootDir) where MCP files are written. Default: rootDir. */
121
+ baseDir?: string;
122
+ output?: {
123
+ /** Path template for per-op-file tool handlers. Supports {filename}, {dir}, {area}. Default `{filename}.mcp.ts`. */
124
+ tools?: string;
125
+ /** Path (or template) for the aggregator that assembles the McpToolHandlerMap. Default `mcp.tools.ts`. */
126
+ index?: string;
127
+ /** Path (or template) for the optional POST /mcp route file. Default `mcp.router.ts`. */
128
+ router?: string;
129
+ /**
130
+ * Path template for the model **Zod schema** files the tools import (for arg validation and
131
+ * `z.toJSONSchema`). When omitted, falls back to the `server` sub-config's `output.types`
132
+ * (if `server.zod`) or the `zod` sub-config's output. Tools require Zod schemas, not plain types.
133
+ */
134
+ types?: string;
135
+ };
136
+ /** Emit the `mcp.router.ts` route boilerplate. Default true. */
137
+ emitRouter?: boolean;
138
+ /** Mount path used in the emitted router. Default `/mcp`. */
139
+ path?: string;
140
+ /** Import path template for service implementations (same semantics as ServerConfig). */
141
+ servicePathTemplate?: string;
142
+ /** Whether to expose operations marked `internal` as MCP tools. Default false. */
143
+ includeInternal?: boolean;
105
144
  }
106
145
 
146
+ /** Top-level plugin config. Each sub-config that is present enables its sub-generator. */
107
147
  export interface TypescriptPluginConfig {
108
148
  server?: ServerConfig;
109
149
  sdk?: SdkConfig;
110
150
  zod?: ZodConfig;
111
151
  types?: TypesConfig;
152
+ mcp?: McpConfig;
112
153
  }
113
154
 
114
155
  // ─── Caching constants ─────────────────────────────────────────────────────
@@ -165,6 +206,7 @@ async function runTypescriptCodegen(
165
206
  if (config.sdk) collectSdkOutput(config.sdk, rootDir, inputs, units, globalFiles);
166
207
  if (config.zod) collectZodOutput(config.zod, rootDir, inputs, units);
167
208
  if (config.types) collectTypesOutput(config.types, rootDir, inputs, units);
209
+ if (config.mcp) collectMcpOutput(config.mcp, config, rootDir, inputs, units, globalFiles);
168
210
 
169
211
  const result = runIncrementalCodegen({
170
212
  codegenVersion: TYPESCRIPT_CODEGEN_VERSION,
@@ -325,6 +367,8 @@ function collectServerOutput(
325
367
  currentOutPath: typeOutPath,
326
368
  modelsWithInput,
327
369
  modelsWithOutput,
370
+ // These types are consumed by Koa handlers, so `binary` is a Buffer, not a Blob.
371
+ target: 'server' as const,
328
372
  };
329
373
  const content = config.zod ? generateContract(ast, renderCtx) : generatePlainTypes(ast, renderCtx);
330
374
  return [{ relativePath: typeOutPath, content }];
@@ -831,13 +875,143 @@ function collectTypesOutput(
831
875
  render: () => [
832
876
  {
833
877
  relativePath: outPath,
834
- content: generatePlainTypes(ast, { modelOutPaths, currentOutPath: outPath, modelsWithInput, modelsWithOutput }),
878
+ content: generatePlainTypes(ast, {
879
+ modelOutPaths,
880
+ currentOutPath: outPath,
881
+ modelsWithInput,
882
+ modelsWithOutput,
883
+ target: config.target,
884
+ }),
835
885
  },
836
886
  ],
837
887
  });
838
888
  }
839
889
  }
840
890
 
891
+ // ─── MCP sub-generator ─────────────────────────────────────────────────────
892
+
893
+ /**
894
+ * Resolve where the model **Zod schema** files live so the MCP tools can import them (for arg
895
+ * validation + `z.toJSONSchema`). Precedence: explicit `mcp.output.types` → the `server` sub-config's
896
+ * `output.types` (only when `server.zod`) → the `zod` sub-config's output. Returns an empty map when
897
+ * none resolve (imports then fall back to a colocated `./<name>.js` guess).
898
+ */
899
+ function resolveMcpModelOutPaths(
900
+ config: TypescriptPluginConfig,
901
+ rootDir: string,
902
+ contractRoots: readonly ContractRootNode[],
903
+ commonRoot: string,
904
+ modelsWithInput: Set<string>,
905
+ modelsWithOutput: Set<string>,
906
+ ): Map<string, string> {
907
+ const map = new Map<string, string>();
908
+ let base: string;
909
+ let template: string | undefined;
910
+ let suffix: string;
911
+ if (config.mcp?.output?.types) {
912
+ base = resolve(rootDir, config.mcp.baseDir ?? '.');
913
+ template = config.mcp.output.types;
914
+ suffix = '.ts';
915
+ } else if (config.server?.zod && config.server.output?.types) {
916
+ base = resolve(rootDir, config.server.baseDir ?? '.');
917
+ template = config.server.output.types;
918
+ suffix = '.ts';
919
+ } else if (config.zod) {
920
+ base = resolve(rootDir, config.zod.baseDir ?? '.');
921
+ template = config.zod.output;
922
+ suffix = '.schema.ts';
923
+ } else {
924
+ return map;
925
+ }
926
+
927
+ for (const ast of contractRoots) {
928
+ const outPath = computeContractOutPath(ast.file, base, template, suffix, commonRoot, ast.meta);
929
+ for (const model of ast.models) {
930
+ map.set(model.name, outPath);
931
+ if (modelsWithInput.has(model.name)) map.set(`${model.name}Input`, outPath);
932
+ if (modelsWithOutput.has(model.name)) map.set(`${model.name}Output`, outPath);
933
+ }
934
+ }
935
+ return map;
936
+ }
937
+
938
+ function collectMcpOutput(
939
+ config: McpConfig,
940
+ fullConfig: TypescriptPluginConfig,
941
+ rootDir: string,
942
+ inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
943
+ units: IncrementalUnit[],
944
+ globalFiles: IncrementalOutputFile[],
945
+ ): void {
946
+ const mcpBase = resolve(rootDir, config.baseDir ?? '.');
947
+ const modelsWithInput = inputs.modelsWithInput as Set<string>;
948
+ const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
949
+ const modelMap = buildModelMap(inputs.contractRoots);
950
+ const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
951
+ const commonRoot = commonDir(allFiles, rootDir);
952
+ const subConfigKey = stableSubConfig(config);
953
+ const includeInternal = config.includeInternal ?? false;
954
+
955
+ const modelOutPaths = resolveMcpModelOutPaths(fullConfig, rootDir, inputs.contractRoots, commonRoot, modelsWithInput, modelsWithOutput);
956
+
957
+ // ── Per-op-root tool-handler units (only files with MCP-exposed ops) ──
958
+ const entries: { outPath: string; registerFn: string }[] = [];
959
+ for (const ast of inputs.opRoots) {
960
+ if (!hasMcpOperations(ast, includeInternal)) continue;
961
+ const outPath = computeOpOutPath(ast.file, mcpBase, config.output?.tools, '.mcp.ts', commonRoot, ast.meta);
962
+ const refs = collectOpRootRefs(ast, modelMap);
963
+ const fingerprint = hashFingerprint({
964
+ kind: 'mcp-tools',
965
+ v: TYPESCRIPT_CODEGEN_VERSION,
966
+ outPath,
967
+ root: ast,
968
+ outPathSlice: sliceOutPathMap(refs, modelOutPaths, modelsWithInput, modelsWithOutput),
969
+ modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
970
+ modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
971
+ servicePathTemplate: config.servicePathTemplate ?? null,
972
+ includeInternal,
973
+ sub: subConfigKey,
974
+ });
975
+ units.push({
976
+ key: `mcp-tools::${outPath}`,
977
+ fingerprint,
978
+ render: () => [
979
+ {
980
+ relativePath: outPath,
981
+ content: generateMcpFile(ast, {
982
+ outPath,
983
+ modelOutPaths,
984
+ modelsWithInput,
985
+ modelsWithOutput,
986
+ servicePathTemplate: config.servicePathTemplate,
987
+ includeInternal,
988
+ }),
989
+ },
990
+ ],
991
+ });
992
+ entries.push({ outPath, registerFn: deriveMcpRegisterFnName(ast.file) });
993
+ }
994
+
995
+ if (entries.length === 0) return;
996
+
997
+ // ── Aggregator (global) ──
998
+ const indexPath = join(mcpBase, config.output?.index ?? 'mcp.tools.ts');
999
+ const aggregatorEntries = entries
1000
+ .map(e => {
1001
+ let rel = relative(dirname(indexPath), e.outPath).replace(/\.ts$/, '.js');
1002
+ if (!rel.startsWith('.')) rel = './' + rel;
1003
+ return { registerFn: e.registerFn, importPath: rel };
1004
+ })
1005
+ .sort((a, b) => a.registerFn.localeCompare(b.registerFn));
1006
+ globalFiles.push({ relativePath: indexPath, content: generateMcpAggregator(aggregatorEntries) });
1007
+
1008
+ // ── Router (global, optional) ──
1009
+ if (config.emitRouter !== false) {
1010
+ const routerPath = join(mcpBase, config.output?.router ?? 'mcp.router.ts');
1011
+ globalFiles.push({ relativePath: routerPath, content: generateMcpRouter({ path: config.path }) });
1012
+ }
1013
+ }
1014
+
841
1015
  // ─── Manifest IO + cleanup ─────────────────────────────────────────────────
842
1016
 
843
1017
  function readManifest(manifestPath: string): IncrementalManifest {