@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/.turbo/turbo-build$colon$ci.log +5 -5
- package/.turbo/turbo-test$colon$ci.log +21 -19
- package/CHANGELOG.md +16 -0
- package/README.md +4 -0
- package/dist/codegen-contract.d.ts +7 -0
- package/dist/codegen-contract.d.ts.map +1 -1
- package/dist/codegen-mcp.d.ts +38 -0
- package/dist/codegen-mcp.d.ts.map +1 -0
- package/dist/codegen-operation.d.ts +42 -1
- package/dist/codegen-operation.d.ts.map +1 -1
- package/dist/codegen-plain-types.d.ts +3 -0
- package/dist/codegen-plain-types.d.ts.map +1 -1
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +686 -96
- package/dist/index.js.map +1 -1
- package/dist/ts-render.d.ts +21 -3
- package/dist/ts-render.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/codegen-contract.ts +7 -0
- package/src/codegen-mcp.ts +501 -0
- package/src/codegen-operation.ts +104 -10
- package/src/codegen-plain-types.ts +47 -22
- package/src/index.ts +175 -1
- package/src/ts-render.ts +52 -32
- package/tests/codegen-mcp.test.ts +246 -0
- package/tests/codegen-operation.test.ts +89 -0
- package/tests/codegen-plain-types.test.ts +48 -0
- package/tests/codegen-server.test.ts +37 -0
- package/tests/pipeline.test.ts +59 -0
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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', () => {
|