@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.
package/src/ts-render.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import type { ContractTypeNode, FieldNode, ScalarTypeNode } from '@contractkit/core';
2
2
 
3
+ /** Declaration emitted into generated files that reference the `json` scalar. */
3
4
  export const JSON_VALUE_TYPE_DECL = 'export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };';
4
5
 
6
+ /** Quote a property name unless it is already a valid bare TypeScript identifier. */
5
7
  export function quoteKey(name: string): string {
6
8
  return /^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(name) ? name : `'${name}'`;
7
9
  }
@@ -31,12 +33,25 @@ export function headerNameToProperty(name: string): string {
31
33
 
32
34
  // ─── TypeScript type rendering ────────────────────────────────────────────
33
35
 
34
- export function renderTsType(type: ContractTypeNode): string {
36
+ /**
37
+ * Which runtime the emitted types describe. A few scalars have no single correct TypeScript
38
+ * type: `binary` is a `Blob` in a fetch-based client but a `Buffer` on a Node server. Everything
39
+ * else renders identically for both targets. Defaults to `'client'`.
40
+ */
41
+ export type TsRenderTarget = 'client' | 'server';
42
+
43
+ /**
44
+ * Render a contract type as a plain TypeScript type expression. Model refs render as their bare
45
+ * name; use `renderInputTsType` / `renderOutputTsType` to substitute Input/Output variants.
46
+ *
47
+ * @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
48
+ */
49
+ export function renderTsType(type: ContractTypeNode, target: TsRenderTarget = 'client'): string {
35
50
  switch (type.kind) {
36
51
  case 'scalar':
37
- return renderTsScalar(type.name);
52
+ return renderTsScalar(type.name, target);
38
53
  case 'array': {
39
- const inner = renderTsType(type.item);
54
+ const inner = renderTsType(type.item, target);
40
55
  const needsParens =
41
56
  type.item.kind === 'union' ||
42
57
  type.item.kind === 'discriminatedUnion' ||
@@ -45,31 +60,31 @@ export function renderTsType(type: ContractTypeNode): string {
45
60
  return needsParens ? `(${inner})[]` : `${inner}[]`;
46
61
  }
47
62
  case 'tuple':
48
- return `[${type.items.map(renderTsType).join(', ')}]`;
63
+ return `[${type.items.map(i => renderTsType(i, target)).join(', ')}]`;
49
64
  case 'record':
50
- return `Record<${renderTsType(type.key)}, ${renderTsType(type.value)}>`;
65
+ return `Record<${renderTsType(type.key, target)}, ${renderTsType(type.value, target)}>`;
51
66
  case 'enum':
52
67
  return type.values.map(v => `'${escapeSingleQuoted(v)}'`).join(' | ');
53
68
  case 'literal':
54
69
  return typeof type.value === 'string' ? `'${escapeSingleQuoted(type.value)}'` : String(type.value);
55
70
  case 'union':
56
- return type.members.map(renderTsType).join(' | ');
71
+ return type.members.map(m => renderTsType(m, target)).join(' | ');
57
72
  case 'discriminatedUnion':
58
- return type.members.map(renderTsType).join(' | ');
73
+ return type.members.map(m => renderTsType(m, target)).join(' | ');
59
74
  case 'intersection':
60
- return type.members.map(renderTsType).join(' & ');
75
+ return type.members.map(m => renderTsType(m, target)).join(' & ');
61
76
  case 'ref':
62
77
  return type.name;
63
78
  case 'lazy':
64
- return renderTsType(type.inner);
79
+ return renderTsType(type.inner, target);
65
80
  case 'inlineObject':
66
- return renderTsInlineObject(type.fields);
81
+ return renderTsInlineObject(type.fields, target);
67
82
  default:
68
83
  return 'unknown';
69
84
  }
70
85
  }
71
86
 
72
- function renderTsScalar(name: ScalarTypeNode['name']): string {
87
+ function renderTsScalar(name: ScalarTypeNode['name'], target: TsRenderTarget): string {
73
88
  switch (name) {
74
89
  case 'string':
75
90
  case 'email':
@@ -96,7 +111,8 @@ function renderTsScalar(name: ScalarTypeNode['name']): string {
96
111
  case 'object':
97
112
  return 'Record<string, unknown>';
98
113
  case 'binary':
99
- return 'Blob';
114
+ // Node servers hand the handler a Buffer (matching `_ZodBinary`); fetch clients get a Blob.
115
+ return target === 'server' ? 'Buffer' : 'Blob';
100
116
  case 'json':
101
117
  return 'JsonValue';
102
118
  default: {
@@ -106,10 +122,10 @@ function renderTsScalar(name: ScalarTypeNode['name']): string {
106
122
  }
107
123
  }
108
124
 
109
- function renderTsInlineObject(fields: FieldNode[]): string {
125
+ function renderTsInlineObject(fields: FieldNode[], target: TsRenderTarget): string {
110
126
  const entries = fields.map(f => {
111
127
  const opt = f.optional ? '?' : '';
112
- return `${quoteKey(f.name)}${opt}: ${renderTsType(f.type)}`;
128
+ return `${quoteKey(f.name)}${opt}: ${renderTsType(f.type, target)}`;
113
129
  });
114
130
  return `{ ${entries.join('; ')} }`;
115
131
  }
@@ -118,14 +134,16 @@ function renderTsInlineObject(fields: FieldNode[]): string {
118
134
  * Like renderTsType, but substitutes model refs with their Input variant
119
135
  * when the model has visibility modifiers. Used for request-side types
120
136
  * (body, params, query, headers).
137
+ *
138
+ * @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
121
139
  */
122
- export function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<string>): string {
123
- if (!modelsWithInput || modelsWithInput.size === 0) return renderTsType(type);
140
+ export function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<string>, target: TsRenderTarget = 'client'): string {
141
+ if (!modelsWithInput || modelsWithInput.size === 0) return renderTsType(type, target);
124
142
  switch (type.kind) {
125
143
  case 'ref':
126
144
  return modelsWithInput.has(type.name) ? `${type.name}Input` : type.name;
127
145
  case 'array': {
128
- const inner = renderInputTsType(type.item, modelsWithInput);
146
+ const inner = renderInputTsType(type.item, modelsWithInput, target);
129
147
  const needsParens =
130
148
  type.item.kind === 'union' ||
131
149
  type.item.kind === 'discriminatedUnion' ||
@@ -134,17 +152,17 @@ export function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<
134
152
  return needsParens ? `(${inner})[]` : `${inner}[]`;
135
153
  }
136
154
  case 'intersection':
137
- return type.members.map(m => renderInputTsType(m, modelsWithInput)).join(' & ');
155
+ return type.members.map(m => renderInputTsType(m, modelsWithInput, target)).join(' & ');
138
156
  case 'union':
139
- return type.members.map(m => renderInputTsType(m, modelsWithInput)).join(' | ');
157
+ return type.members.map(m => renderInputTsType(m, modelsWithInput, target)).join(' | ');
140
158
  case 'discriminatedUnion':
141
- return type.members.map(m => renderInputTsType(m, modelsWithInput)).join(' | ');
159
+ return type.members.map(m => renderInputTsType(m, modelsWithInput, target)).join(' | ');
142
160
  case 'inlineObject':
143
- return `{ ${type.fields.map(f => `${quoteKey(f.name)}${f.optional ? '?' : ''}: ${renderInputTsType(f.type, modelsWithInput)}`).join('; ')} }`;
161
+ return `{ ${type.fields.map(f => `${quoteKey(f.name)}${f.optional ? '?' : ''}: ${renderInputTsType(f.type, modelsWithInput, target)}`).join('; ')} }`;
144
162
  case 'lazy':
145
- return renderInputTsType(type.inner, modelsWithInput);
163
+ return renderInputTsType(type.inner, modelsWithInput, target);
146
164
  default:
147
- return renderTsType(type);
165
+ return renderTsType(type, target);
148
166
  }
149
167
  }
150
168
 
@@ -153,14 +171,16 @@ export function renderInputTsType(type: ContractTypeNode, modelsWithInput?: Set<
153
171
  * (post-transform wire shape) when the model has format(output=...) or
154
172
  * transitively references one. Used for response-side types in routers
155
173
  * and SDK return types.
174
+ *
175
+ * @param target Runtime the type describes; only `binary` differs (`Buffer` vs `Blob`).
156
176
  */
157
- export function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Set<string>): string {
158
- if (!modelsWithOutput || modelsWithOutput.size === 0) return renderTsType(type);
177
+ export function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Set<string>, target: TsRenderTarget = 'client'): string {
178
+ if (!modelsWithOutput || modelsWithOutput.size === 0) return renderTsType(type, target);
159
179
  switch (type.kind) {
160
180
  case 'ref':
161
181
  return modelsWithOutput.has(type.name) ? `${type.name}Output` : type.name;
162
182
  case 'array': {
163
- const inner = renderOutputTsType(type.item, modelsWithOutput);
183
+ const inner = renderOutputTsType(type.item, modelsWithOutput, target);
164
184
  const needsParens =
165
185
  type.item.kind === 'union' ||
166
186
  type.item.kind === 'discriminatedUnion' ||
@@ -169,16 +189,16 @@ export function renderOutputTsType(type: ContractTypeNode, modelsWithOutput?: Se
169
189
  return needsParens ? `(${inner})[]` : `${inner}[]`;
170
190
  }
171
191
  case 'intersection':
172
- return type.members.map(m => renderOutputTsType(m, modelsWithOutput)).join(' & ');
192
+ return type.members.map(m => renderOutputTsType(m, modelsWithOutput, target)).join(' & ');
173
193
  case 'union':
174
- return type.members.map(m => renderOutputTsType(m, modelsWithOutput)).join(' | ');
194
+ return type.members.map(m => renderOutputTsType(m, modelsWithOutput, target)).join(' | ');
175
195
  case 'discriminatedUnion':
176
- return type.members.map(m => renderOutputTsType(m, modelsWithOutput)).join(' | ');
196
+ return type.members.map(m => renderOutputTsType(m, modelsWithOutput, target)).join(' | ');
177
197
  case 'inlineObject':
178
- return `{ ${type.fields.map(f => `${quoteKey(f.name)}${f.optional ? '?' : ''}: ${renderOutputTsType(f.type, modelsWithOutput)}`).join('; ')} }`;
198
+ return `{ ${type.fields.map(f => `${quoteKey(f.name)}${f.optional ? '?' : ''}: ${renderOutputTsType(f.type, modelsWithOutput, target)}`).join('; ')} }`;
179
199
  case 'lazy':
180
- return renderOutputTsType(type.inner, modelsWithOutput);
200
+ return renderOutputTsType(type.inner, modelsWithOutput, target);
181
201
  default:
182
- return renderTsType(type);
202
+ return renderTsType(type, target);
183
203
  }
184
204
  }
@@ -0,0 +1,246 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import type { McpConfigNode } from '@contractkit/core';
3
+ import {
4
+ generateMcpFile,
5
+ generateMcpAggregator,
6
+ generateMcpRouter,
7
+ hasMcpOperations,
8
+ deriveMcpRegisterFnName,
9
+ } from '../src/codegen-mcp.js';
10
+ import { opRoot, opRoute, opOperation, opParam, opRequest, opResponse, scalarType, loc } from './helpers.js';
11
+
12
+ function mcpBlock(over: Partial<McpConfigNode>): McpConfigNode {
13
+ return { loc: loc(), ...over };
14
+ }
15
+
16
+ describe('hasMcpOperations', () => {
17
+ it('is false when no op is flagged', () => {
18
+ const root = opRoot([opRoute('/users', [opOperation('get', { responses: [opResponse(200, 'User', 'application/json')] })])]);
19
+ expect(hasMcpOperations(root)).toBe(false);
20
+ });
21
+
22
+ it('is true when an op is flagged', () => {
23
+ const root = opRoot([opRoute('/users', [opOperation('get', { mcp: true, responses: [opResponse(200, 'User', 'application/json')] })])]);
24
+ expect(hasMcpOperations(root)).toBe(true);
25
+ });
26
+
27
+ it('excludes internal ops unless includeInternal', () => {
28
+ const root = opRoot([
29
+ opRoute('/users', [opOperation('get', { mcp: true, responses: [opResponse(200, 'User', 'application/json')] })], undefined, ['internal']),
30
+ ]);
31
+ expect(hasMcpOperations(root)).toBe(false);
32
+ expect(hasMcpOperations(root, true)).toBe(true);
33
+ });
34
+ });
35
+
36
+ describe('generateMcpFile', () => {
37
+ describe('op selection', () => {
38
+ it('emits a tool class only for flagged ops', () => {
39
+ const root = opRoot([
40
+ opRoute('/users', [
41
+ opOperation('get', { sdk: 'listUsers', mcp: true, responses: [opResponse(200, 'User', 'application/json')] }),
42
+ opOperation('post', { sdk: 'createUser', request: opRequest('User'), responses: [opResponse(201, 'User', 'application/json')] }),
43
+ ]),
44
+ ]);
45
+ const out = generateMcpFile(root);
46
+ expect(out).toContain('export class ListUsersMcpTool');
47
+ expect(out).not.toContain('CreateUserMcpTool');
48
+ });
49
+
50
+ it('skips mcp: false', () => {
51
+ const root = opRoot([opRoute('/users', [opOperation('get', { sdk: 'listUsers', mcp: false, responses: [opResponse(200, 'User', 'application/json')] })])]);
52
+ const out = generateMcpFile(root);
53
+ expect(out).not.toContain('implements McpToolHandler');
54
+ });
55
+
56
+ it('skips internal ops unless includeInternal', () => {
57
+ const root = opRoot([
58
+ opRoute('/users', [opOperation('get', { sdk: 'listUsers', mcp: true, responses: [opResponse(200, 'User', 'application/json')] })], undefined, [
59
+ 'internal',
60
+ ]),
61
+ ]);
62
+ expect(generateMcpFile(root)).not.toContain('ListUsersMcpTool');
63
+ expect(generateMcpFile(root, { includeInternal: true })).toContain('ListUsersMcpTool');
64
+ });
65
+ });
66
+
67
+ describe('tool name + class name', () => {
68
+ it('uses explicit mcp.name verbatim', () => {
69
+ const root = opRoot([
70
+ opRoute('/routes', [opOperation('post', { mcp: mcpBlock({ name: 'searchRoutes' }), request: opRequest('Query'), responses: [opResponse(200, 'RouteList', 'application/json')] })]),
71
+ ]);
72
+ const out = generateMcpFile(root);
73
+ expect(out).toContain("name: 'searchRoutes'");
74
+ expect(out).toContain('export class SearchRoutesMcpTool');
75
+ });
76
+
77
+ it('snake_cases the sdk field', () => {
78
+ const root = opRoot([opRoute('/users', [opOperation('get', { sdk: 'listAllUsers', mcp: true, responses: [opResponse(200, 'User', 'application/json')] })])]);
79
+ const out = generateMcpFile(root);
80
+ expect(out).toContain("name: 'list_all_users'");
81
+ expect(out).toContain('export class ListAllUsersMcpTool');
82
+ });
83
+
84
+ it('snake_cases the name field', () => {
85
+ const root = opRoot([opRoute('/users', [opOperation('get', { name: 'Get User', mcp: true, responses: [opResponse(200, 'User', 'application/json')] })])]);
86
+ expect(generateMcpFile(root)).toContain("name: 'get_user'");
87
+ });
88
+
89
+ it('infers snake_case name from method + path', () => {
90
+ const root = opRoot([
91
+ opRoute('/payments/{id}', [opOperation('get', { mcp: true, responses: [opResponse(200, 'Payment', 'application/json')] })], [opParam('id', scalarType('uuid'))]),
92
+ ]);
93
+ expect(generateMcpFile(root)).toContain("name: 'get_payments_by_id'");
94
+ });
95
+ });
96
+
97
+ describe('definition metadata', () => {
98
+ it('emits title, description, and only the defined annotations', () => {
99
+ const root = opRoot([
100
+ opRoute('/payments/{id}', [
101
+ opOperation('get', {
102
+ mcp: mcpBlock({ title: 'Get Payment', description: 'Fetch a payment by id.', readOnlyHint: true, idempotentHint: true, destructiveHint: false }),
103
+ responses: [opResponse(200, 'Payment', 'application/json')],
104
+ }),
105
+ ], [opParam('id', scalarType('uuid'))]),
106
+ ]);
107
+ const out = generateMcpFile(root);
108
+ expect(out).toContain("title: 'Get Payment'");
109
+ expect(out).toContain("description: 'Fetch a payment by id.'");
110
+ expect(out).toContain('annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }');
111
+ expect(out).not.toContain('openWorldHint');
112
+ });
113
+
114
+ it('omits annotations when no hints set', () => {
115
+ const root = opRoot([opRoute('/users', [opOperation('get', { mcp: true, responses: [opResponse(200, 'User', 'application/json')] })])]);
116
+ expect(generateMcpFile(root)).not.toContain('annotations:');
117
+ });
118
+
119
+ it('falls back to op description', () => {
120
+ const root = opRoot([opRoute('/users', [opOperation('get', { mcp: true, description: 'List users.', responses: [opResponse(200, 'User', 'application/json')] })])]);
121
+ expect(generateMcpFile(root)).toContain("description: 'List users.'");
122
+ });
123
+ });
124
+
125
+ describe('input schema + validation', () => {
126
+ it('folds path params into a shared args const used for schema and validation', () => {
127
+ const root = opRoot([
128
+ opRoute('/payments/{id}', [opOperation('get', { mcp: true, responses: [opResponse(200, 'Payment', 'application/json')] })], [opParam('id', scalarType('uuid'))]),
129
+ ]);
130
+ const out = generateMcpFile(root);
131
+ expect(out).toContain('const GetPaymentsByIdArgs = z.object({ id: z.uuid() });');
132
+ expect(out).toContain("inputSchema: z.toJSONSchema(GetPaymentsByIdArgs, { unrepresentable: 'any' }) as Tool['inputSchema']");
133
+ expect(out).toContain('const { id } = await parseAndValidate(args, GetPaymentsByIdArgs);');
134
+ });
135
+
136
+ it('nests the request body under a body field', () => {
137
+ const root = opRoot([
138
+ opRoute('/payments', [opOperation('post', { sdk: 'createPayment', mcp: true, request: opRequest('PaymentInput'), responses: [opResponse(201, 'Payment', 'application/json')] })]),
139
+ ]);
140
+ const out = generateMcpFile(root);
141
+ expect(out).toContain('const CreatePaymentArgs = z.object({ body: PaymentInput });');
142
+ expect(out).toContain('const { body } = await parseAndValidate(args, CreatePaymentArgs);');
143
+ });
144
+ });
145
+
146
+ describe('service call + result', () => {
147
+ it('injects the declared service and calls it', () => {
148
+ const root = opRoot([
149
+ opRoute('/payments/{id}', [opOperation('get', { mcp: true, service: 'PaymentsService.getById', responses: [opResponse(200, 'Payment', 'application/json')] })], [opParam('id', scalarType('uuid'))]),
150
+ ]);
151
+ const out = generateMcpFile(root);
152
+ expect(out).toContain('constructor(private readonly service: PaymentsService) {}');
153
+ expect(out).toContain('const result = await this.service.getById(id);');
154
+ expect(out).toContain('structuredContent: result');
155
+ });
156
+
157
+ it('returns bare content (no structuredContent / outputSchema) for a void response', () => {
158
+ const root = opRoot([
159
+ opRoute('/payments/{id}', [opOperation('delete', { mcp: true, service: 'PaymentsService.remove', responses: [opResponse(204)] })], [opParam('id', scalarType('uuid'))]),
160
+ ]);
161
+ const out = generateMcpFile(root);
162
+ expect(out).toContain('await this.service.remove(id);');
163
+ expect(out).toContain("return { content: [{ type: 'text', text: 'OK' }] };");
164
+ expect(out).not.toContain('structuredContent');
165
+ expect(out).not.toContain('outputSchema');
166
+ });
167
+
168
+ it('emits outputSchema for an object response', () => {
169
+ const root = opRoot([
170
+ opRoute('/payments/{id}', [opOperation('get', { mcp: true, service: 'PaymentsService.getById', responses: [opResponse(200, 'Payment', 'application/json')] })], [opParam('id', scalarType('uuid'))]),
171
+ ]);
172
+ expect(generateMcpFile(root)).toContain("outputSchema: z.toJSONSchema(Payment, { unrepresentable: 'any' }) as Tool['outputSchema']");
173
+ });
174
+ });
175
+
176
+ describe('imports + registration', () => {
177
+ it('imports the MCP + injectkit primitives and emits a per-file register fn', () => {
178
+ const root = opRoot(
179
+ [opRoute('/payments/{id}', [opOperation('get', { mcp: true, service: 'PaymentsService.getById', responses: [opResponse(200, 'Payment', 'application/json')] })], [opParam('id', scalarType('uuid'))])],
180
+ 'payments.op',
181
+ );
182
+ const out = generateMcpFile(root);
183
+ expect(out).toContain("import { Injectable, type Container } from 'injectkit';");
184
+ expect(out).toContain("import type { McpToolHandler, McpToolHandlerMap, McpToolContext } from '@maroonedsoftware/mcp';");
185
+ expect(out).toContain("import { parseAndValidate } from '@maroonedsoftware/zod';");
186
+ expect(out).toContain('export function registerPaymentsMcpTools(map: McpToolHandlerMap, container: Container): void {');
187
+ expect(out).toContain("map.set('get_payments_by_id', container.get(GetPaymentsByIdMcpTool));");
188
+ });
189
+
190
+ it('resolves service + schema imports via modelOutPaths', () => {
191
+ const root = opRoot(
192
+ [opRoute('/payments', [opOperation('post', { sdk: 'createPayment', mcp: true, service: 'PaymentsService.create', request: opRequest('PaymentInput'), responses: [opResponse(201, 'Payment', 'application/json')] })])],
193
+ 'payments.op',
194
+ );
195
+ const modelOutPaths = new Map<string, string>([
196
+ ['Payment', '/api/src/types/payment.ts'],
197
+ ['PaymentInput', '/api/src/types/payment.ts'],
198
+ ]);
199
+ const out = generateMcpFile(root, {
200
+ outPath: '/api/src/mcp/payments.mcp.ts',
201
+ modelOutPaths,
202
+ modelsWithInput: new Set(['Payment']),
203
+ servicePathTemplate: '#modules/{kebab}/{kebab}.service.js',
204
+ });
205
+ expect(out).toContain("import { PaymentsService } from '#modules/payments/payments.service.js';");
206
+ expect(out).toContain("import { Payment, PaymentInput } from '../types/payment.js';");
207
+ });
208
+ });
209
+ });
210
+
211
+ describe('generateMcpAggregator', () => {
212
+ it('imports each register fn and assembles one map', () => {
213
+ const out = generateMcpAggregator([
214
+ { registerFn: 'registerPaymentsMcpTools', importPath: './payments.mcp.js' },
215
+ { registerFn: 'registerUsersMcpTools', importPath: './users.mcp.js' },
216
+ ]);
217
+ expect(out).toContain("import { McpToolHandlerMap } from '@maroonedsoftware/mcp';");
218
+ expect(out).toContain("import { registerPaymentsMcpTools } from './payments.mcp.js';");
219
+ expect(out).toContain('export function registerMcpTools(container: Container): McpToolHandlerMap {');
220
+ expect(out).toContain('const map = new McpToolHandlerMap();');
221
+ expect(out).toContain('registerPaymentsMcpTools(map, container);');
222
+ expect(out).toContain('registerUsersMcpTools(map, container);');
223
+ expect(out).toContain('container.register(McpToolHandlerMap, { useValue: map });');
224
+ });
225
+ });
226
+
227
+ describe('generateMcpRouter', () => {
228
+ it('emits a ServerKit route wired to the dispatcher at the configured path', () => {
229
+ const out = generateMcpRouter({ path: '/mcp' });
230
+ expect(out).toContain("import { McpDispatcher, createMcpRequestContext, MCP_AUTH_POLICY } from '@maroonedsoftware/mcp';");
231
+ expect(out).toContain("router.post('/mcp'");
232
+ expect(out).toContain('ctx.container.get(McpDispatcher)');
233
+ expect(out).toContain("dispatcher.sessionMode === 'stateful'");
234
+ });
235
+
236
+ it('defaults the mount path to /mcp', () => {
237
+ expect(generateMcpRouter()).toContain("router.post('/mcp'");
238
+ });
239
+ });
240
+
241
+ describe('deriveMcpRegisterFnName', () => {
242
+ it('derives from the op-root filename', () => {
243
+ expect(deriveMcpRegisterFnName('payments.op')).toBe('registerPaymentsMcpTools');
244
+ expect(deriveMcpRegisterFnName('ledger.categories.op')).toBe('registerLedgerCategoriesMcpTools');
245
+ });
246
+ });
@@ -103,6 +103,24 @@ describe('generateOperation', () => {
103
103
  });
104
104
  });
105
105
 
106
+ // ─── Handler signature ─────────────────────────────────────────
107
+
108
+ describe('handler signature', () => {
109
+ it('emits a single-parameter handler without the unused next argument', () => {
110
+ const root = opRoot([opRoute('/users', [opOperation('get', { security: SECURITY_NONE })])]);
111
+ const output = generateOp(root);
112
+ expect(output).toContain(".get('/users', async ctx => {");
113
+ expect(output).not.toContain('ctx, next');
114
+ });
115
+
116
+ it('omits next on handlers that carry middleware', () => {
117
+ const root = opRoot([opRoute('/users', [opOperation('post', { request: opRequest('CreateUser') })])]);
118
+ const output = generateOp(root);
119
+ expect(output).toContain('async ctx => {');
120
+ expect(output).not.toContain('ctx, next');
121
+ });
122
+ });
123
+
106
124
  // ─── Handler generation — GET ──────────────────────────────────
107
125
 
108
126
  describe('GET handlers', () => {
@@ -640,6 +658,77 @@ describe('generateOperation', () => {
640
658
  const output = generateOp(root, { modelsWithOutput: new Set(['AuthToken']) });
641
659
  expect(output).toContain('result: AuthTokenOutput[]');
642
660
  });
661
+
662
+ // Scalar response bodies used to emit the .ck scalar name verbatim (`result: binary`),
663
+ // which only happened to compile for `string`.
664
+ describe('scalar response bodies map to the server-side TypeScript type', () => {
665
+ const cases: Array<[string, string]> = [
666
+ ['binary', 'Buffer'],
667
+ ['int', 'number'],
668
+ ['number', 'number'],
669
+ ['bigint', 'bigint'],
670
+ ['boolean', 'boolean'],
671
+ ['string', 'string'],
672
+ ['uuid', 'string'],
673
+ ['email', 'string'],
674
+ ['url', 'string'],
675
+ ['datetime', 'DateTime'],
676
+ ['date', 'DateTime'],
677
+ ['time', 'DateTime'],
678
+ ['duration', 'Duration'],
679
+ ['interval', 'string'],
680
+ ['json', '_JsonValue'],
681
+ ['object', 'Record<string, unknown>'],
682
+ ['unknown', 'unknown'],
683
+ ['null', 'null'],
684
+ ];
685
+
686
+ for (const [scalar, tsType] of cases) {
687
+ it(`renders ${scalar} as ${tsType}`, () => {
688
+ const root = opRoot([
689
+ opRoute('/x', [
690
+ opOperation('get', {
691
+ responses: [opResponse(200, scalarType(scalar as never), 'application/octet-stream')],
692
+ }),
693
+ ]),
694
+ ]);
695
+ expect(generateOp(root)).toContain(`const result: ${tsType} = await service.list();`);
696
+ });
697
+ }
698
+
699
+ it('renders an array of binary as Buffer[]', () => {
700
+ const root = opRoot([
701
+ opRoute('/x', [opOperation('get', { responses: [opResponse(200, arrayType(scalarType('binary')), 'application/json')] })]),
702
+ ]);
703
+ expect(generateOp(root)).toContain('const result: Buffer[] = await service.list();');
704
+ });
705
+ });
706
+
707
+ describe('luxon imports cover every scalar that references a luxon class', () => {
708
+ it('imports Duration for a duration response body', () => {
709
+ const root = opRoot([opRoute('/x', [opOperation('get', { responses: [opResponse(200, scalarType('duration'), 'application/json')] })])]);
710
+ expect(generateOp(root)).toContain("import { Duration } from 'luxon';");
711
+ });
712
+
713
+ it('imports Interval and emits the _ZodInterval helper for an interval body', () => {
714
+ const root = opRoot([opRoute('/x', [opOperation('get', { responses: [opResponse(200, scalarType('interval'), 'application/json')] })])]);
715
+ const output = generateOp(root);
716
+ expect(output).toContain("import { Interval } from 'luxon';");
717
+ expect(output).toContain('const _ZodInterval =');
718
+ });
719
+
720
+ it('imports DateTime and Duration together when both are used', () => {
721
+ const root = opRoot([
722
+ opRoute('/x', [
723
+ opOperation('post', {
724
+ request: opRequest(scalarType('datetime')),
725
+ responses: [opResponse(200, scalarType('duration'), 'application/json')],
726
+ }),
727
+ ]),
728
+ ]);
729
+ expect(generateOp(root)).toContain("import { DateTime, Duration } from 'luxon';");
730
+ });
731
+ });
643
732
  });
644
733
 
645
734
  // ─── Service inference ────────────────────────────────────────
@@ -98,6 +98,54 @@ describe('generatePlainTypes', () => {
98
98
  expect(output).toContain('o: Record<string, unknown>;');
99
99
  expect(output).toContain('bin: Blob;');
100
100
  });
101
+
102
+ // `binary` is the one scalar with no runtime-independent TypeScript type: a fetch client
103
+ // sees a Blob, a Koa handler sees the Buffer that _ZodBinary validates.
104
+ describe('binary follows the render target', () => {
105
+ const ctx = (target: 'client' | 'server') => ({
106
+ modelOutPaths: new Map<string, string>(),
107
+ currentOutPath: 'out.ts',
108
+ target,
109
+ });
110
+
111
+ it('renders Buffer for the server target', () => {
112
+ const root = contractRoot([model('M', [field('bin', scalarType('binary'))])]);
113
+ expect(generatePlainTypes(root, ctx('server'))).toContain('bin: Buffer;');
114
+ });
115
+
116
+ it('renders Blob for the client target', () => {
117
+ const root = contractRoot([model('M', [field('bin', scalarType('binary'))])]);
118
+ expect(generatePlainTypes(root, ctx('client'))).toContain('bin: Blob;');
119
+ });
120
+
121
+ it('defaults to the client target when unset', () => {
122
+ const root = contractRoot([model('M', [field('bin', scalarType('binary'))])]);
123
+ expect(generatePlainTypes(root)).toContain('bin: Blob;');
124
+ });
125
+
126
+ it('applies to nested and compound positions', () => {
127
+ const root = contractRoot([
128
+ model('M', [
129
+ field('list', arrayType(scalarType('binary'))),
130
+ field('nested', inlineObjectType([field('bin', scalarType('binary'))])),
131
+ field('map', recordType(scalarType('string'), scalarType('binary'))),
132
+ ]),
133
+ ]);
134
+ const output = generatePlainTypes(root, ctx('server'));
135
+ expect(output).toContain('list: Buffer[];');
136
+ expect(output).toContain('nested: { bin: Buffer };');
137
+ expect(output).toContain('map: Record<string, Buffer>;');
138
+ });
139
+
140
+ it('applies to type aliases and their Input variants', () => {
141
+ const root = contractRoot([
142
+ model('Blob1', [field('bin', scalarType('binary')), field('secret', scalarType('binary'), { visibility: 'readonly' })]),
143
+ ]);
144
+ const output = generatePlainTypes(root, { ...ctx('server'), modelsWithInput: new Set(['Blob1']) });
145
+ expect(output).toContain('bin: Buffer;');
146
+ expect(output).not.toContain('Blob;');
147
+ });
148
+ });
101
149
  });
102
150
 
103
151
  // ─── Compound types ───────────────────────────────────────────
@@ -120,6 +120,43 @@ describe('createTypescriptPlugin (server)', () => {
120
120
  expect(typeContent).not.toContain('z.');
121
121
  expect(typeContent).toContain('export interface User');
122
122
  });
123
+
124
+ it('renders binary as Buffer in server plain types', async () => {
125
+ const plugin = createTypescriptPlugin({ server: { output: { types: 'src/types/{filename}.ts' } } }, '/project');
126
+ const ctx = makeCtx('/project');
127
+ const contractRoots = [contractRoot([model('Upload', [field('data', scalarType('binary'))])], '/project/contracts/uploads.ck')];
128
+ await plugin.generateTargets!(inputs([], contractRoots as any), ctx);
129
+ const typeContent = [...ctx.emitted.values()][0]!;
130
+ expect(typeContent).toContain('data: Buffer;');
131
+ expect(typeContent).not.toContain('Blob');
132
+ });
133
+
134
+ it('renders binary as Blob in SDK plain types', async () => {
135
+ const plugin = createTypescriptPlugin({ sdk: { output: { types: 'src/types/{filename}.ts' } } }, '/project');
136
+ const ctx = makeCtx('/project');
137
+ const contractRoots = [contractRoot([model('Upload', [field('data', scalarType('binary'))])], '/project/contracts/uploads.ck')];
138
+ await plugin.generateTargets!(inputs([], contractRoots as any), ctx);
139
+ const typeContent = [...ctx.emitted.values()].find(c => c.includes('interface Upload'))!;
140
+ expect(typeContent).toContain('data: Blob;');
141
+ });
142
+
143
+ it('honors types.target on the standalone types sub-generator', async () => {
144
+ const contractRoots = [contractRoot([model('Upload', [field('data', scalarType('binary'))])], '/project/contracts/uploads.ck')];
145
+
146
+ const serverCtx = makeCtx('/project');
147
+ await createTypescriptPlugin({ types: { output: 'src/{filename}.types.ts', target: 'server' } }, '/project').generateTargets!(
148
+ inputs([], contractRoots as any),
149
+ serverCtx,
150
+ );
151
+ expect([...serverCtx.emitted.values()][0]!).toContain('data: Buffer;');
152
+
153
+ const defaultCtx = makeCtx('/project');
154
+ await createTypescriptPlugin({ types: { output: 'src/{filename}.types.ts' } }, '/project').generateTargets!(
155
+ inputs([], contractRoots as any),
156
+ defaultCtx,
157
+ );
158
+ expect([...defaultCtx.emitted.values()][0]!).toContain('data: Blob;');
159
+ });
123
160
  });
124
161
 
125
162
  describe('generated content', () => {