@contractkit/plugin-typescript 0.28.1 → 0.28.2
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 +17 -15
- package/CHANGELOG.md +6 -0
- 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/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +575 -35
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/codegen-mcp.ts +501 -0
- package/src/codegen-operation.ts +39 -5
- package/src/index.ts +154 -0
- package/tests/codegen-mcp.test.ts +246 -0
- package/tests/codegen-operation.test.ts +18 -0
- package/tests/pipeline.test.ts +59 -0
package/package.json
CHANGED
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
import type { OpRootNode, OpRouteNode, OpOperationNode, McpConfigNode, ParamSource, ContractTypeNode } from '@contractkit/core';
|
|
2
|
+
import { resolveModifiers } from '@contractkit/core';
|
|
3
|
+
import { renderType, renderInputType, pascalToDotCase } from './codegen-contract.js';
|
|
4
|
+
import { inferService, deriveModulePath, buildArgs, deriveBaseName } from './codegen-operation.js';
|
|
5
|
+
import { quoteKey, escapeSingleQuoted } from './ts-render.js';
|
|
6
|
+
import { basename, dirname, relative } from 'node:path';
|
|
7
|
+
|
|
8
|
+
// ─── Options ────────────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export interface McpCodegenOptions {
|
|
11
|
+
/** Absolute path of the file being generated. Used to compute relative imports. */
|
|
12
|
+
outPath?: string;
|
|
13
|
+
/** Map from schema identifier (model name / `${name}Input`) → absolute output file. */
|
|
14
|
+
modelOutPaths?: Map<string, string>;
|
|
15
|
+
/** Model names that have an Input variant schema. */
|
|
16
|
+
modelsWithInput?: Set<string>;
|
|
17
|
+
/** Model names that have an Output variant schema. */
|
|
18
|
+
modelsWithOutput?: Set<string>;
|
|
19
|
+
/** Import-path template for service implementations (same semantics as ServerConfig). */
|
|
20
|
+
servicePathTemplate?: string;
|
|
21
|
+
/** Emit tools for `internal` operations. Default false. */
|
|
22
|
+
includeInternal?: boolean;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ─── MCP flag helpers ─────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
/** The explicit settings block, if the operation used the object form of `mcp:`. */
|
|
28
|
+
function mcpConfig(op: OpOperationNode): McpConfigNode | undefined {
|
|
29
|
+
return op.mcp && typeof op.mcp === 'object' ? op.mcp : undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* True if the root has at least one MCP-exposed operation. With `includeInternal: false`
|
|
34
|
+
* (default) `internal` operations don't count. Mirrors `hasPublicOperations`.
|
|
35
|
+
*/
|
|
36
|
+
export function hasMcpOperations(root: OpRootNode, includeInternal = false): boolean {
|
|
37
|
+
for (const route of root.routes) {
|
|
38
|
+
for (const op of route.operations) {
|
|
39
|
+
if (!op.mcp) continue;
|
|
40
|
+
if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return false;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// ─── Name derivation ────────────────────────────────────────────────────────
|
|
48
|
+
|
|
49
|
+
/** camelCase / PascalCase / spaced / hyphenated → snake_case. */
|
|
50
|
+
function toSnake(s: string): string {
|
|
51
|
+
return s
|
|
52
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1_$2')
|
|
53
|
+
.replace(/[\s\-.]+/g, '_')
|
|
54
|
+
.toLowerCase()
|
|
55
|
+
.replace(/[^a-z0-9_]/g, '')
|
|
56
|
+
.replace(/_+/g, '_')
|
|
57
|
+
.replace(/^_|_$/g, '');
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** snake_case → PascalCase. */
|
|
61
|
+
function toPascal(s: string): string {
|
|
62
|
+
return s
|
|
63
|
+
.split('_')
|
|
64
|
+
.filter(Boolean)
|
|
65
|
+
.map(p => p.charAt(0).toUpperCase() + p.slice(1))
|
|
66
|
+
.join('');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Inferred snake_case tool name from method + path, e.g. GET /payments/{id} → get_payments_by_id. */
|
|
70
|
+
function inferToolName(method: string, path: string): string {
|
|
71
|
+
const parts: string[] = [method.toLowerCase()];
|
|
72
|
+
for (const seg of path.split('/').filter(Boolean)) {
|
|
73
|
+
if (seg.startsWith('{')) {
|
|
74
|
+
parts.push('by', toSnake(seg.slice(1, -1)));
|
|
75
|
+
} else {
|
|
76
|
+
parts.push(toSnake(seg));
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return parts.filter(Boolean).join('_');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Tool name: explicit `mcp.name` (verbatim) → `sdk` → `name` → inferred; derived forms snake_cased. */
|
|
83
|
+
function deriveToolName(op: OpOperationNode, route: OpRouteNode): string {
|
|
84
|
+
const cfg = mcpConfig(op);
|
|
85
|
+
if (cfg?.name) return cfg.name;
|
|
86
|
+
if (op.sdk) return toSnake(op.sdk);
|
|
87
|
+
if (op.name) return toSnake(op.name);
|
|
88
|
+
return inferToolName(op.method, route.path);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Tool class name — PascalCase of the tool name with an `McpTool` suffix for clarity. */
|
|
92
|
+
function deriveToolClassName(toolName: string): string {
|
|
93
|
+
return `${toPascal(toolName)}McpTool`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// ─── Input args schema ────────────────────────────────────────────────────
|
|
97
|
+
|
|
98
|
+
interface ArgsProp {
|
|
99
|
+
key: string;
|
|
100
|
+
expr: string;
|
|
101
|
+
optional: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Build the flat args properties for a tool, matching the router's `buildArgs` variable names. */
|
|
105
|
+
function buildArgsProps(route: OpRouteNode, op: OpOperationNode, modelsWithInput?: Set<string>): ArgsProp[] {
|
|
106
|
+
const props: ArgsProp[] = [];
|
|
107
|
+
|
|
108
|
+
// Path params — spread individually (inline) or as a single `params` object (ref/type).
|
|
109
|
+
if (route.params) {
|
|
110
|
+
if (route.params.kind === 'params') {
|
|
111
|
+
for (const node of route.params.nodes) {
|
|
112
|
+
props.push({ key: node.name, expr: renderInputType(node.type, modelsWithInput), optional: false });
|
|
113
|
+
}
|
|
114
|
+
} else if (route.params.kind === 'ref') {
|
|
115
|
+
props.push({ key: 'params', expr: refSchema(route.params.name, modelsWithInput), optional: false });
|
|
116
|
+
} else {
|
|
117
|
+
props.push({ key: 'params', expr: renderInputType(route.params.node, modelsWithInput), optional: false });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Body — single JSON body maps to a `body` field; multipart/binary/multi bodies aren't cleanly
|
|
122
|
+
// representable as JSON tool args, so they fall back to `z.unknown()` (advisory only).
|
|
123
|
+
const bodies = op.request?.bodies ?? [];
|
|
124
|
+
if (bodies.length === 1 && bodies[0]!.contentType === 'multipart/form-data') {
|
|
125
|
+
props.push({ key: 'multipartBody', expr: 'z.unknown()', optional: false });
|
|
126
|
+
} else if (bodies.length === 1) {
|
|
127
|
+
props.push({ key: 'body', expr: renderInputType(bodies[0]!.bodyType, modelsWithInput), optional: false });
|
|
128
|
+
} else if (bodies.length > 1) {
|
|
129
|
+
props.push({ key: 'body', expr: 'z.unknown()', optional: false });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Query / headers — whole objects, optional.
|
|
133
|
+
if (op.query) props.push({ key: 'query', expr: paramSourceSchema(op.query, modelsWithInput), optional: true });
|
|
134
|
+
if (op.headers) props.push({ key: 'headers', expr: paramSourceSchema(op.headers, modelsWithInput), optional: true });
|
|
135
|
+
|
|
136
|
+
return props;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function refSchema(name: string, modelsWithInput?: Set<string>): string {
|
|
140
|
+
return modelsWithInput?.has(name) ? `${name}Input` : name;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function paramSourceSchema(src: ParamSource, modelsWithInput?: Set<string>): string {
|
|
144
|
+
if (src.kind === 'ref') return refSchema(src.name, modelsWithInput);
|
|
145
|
+
if (src.kind === 'type') return renderInputType(src.node, modelsWithInput);
|
|
146
|
+
const fields = src.nodes.map(n => `${quoteKey(n.name)}: ${renderInputType(n.type, modelsWithInput)}`).join(', ');
|
|
147
|
+
return `z.object({ ${fields} })`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function argsSchemaExpr(props: ArgsProp[]): string {
|
|
151
|
+
if (props.length === 0) return 'z.object({})';
|
|
152
|
+
const fields = props.map(p => `${quoteKey(p.key)}: ${p.expr}${p.optional ? '.optional()' : ''}`).join(', ');
|
|
153
|
+
return `z.object({ ${fields} })`;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// ─── Output schema ──────────────────────────────────────────────────────────
|
|
157
|
+
|
|
158
|
+
/** Primary response = first with a body, else the first response. */
|
|
159
|
+
function primaryResponse(op: OpOperationNode) {
|
|
160
|
+
return op.responses.find(r => r.bodyType) ?? op.responses[0];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** MCP output schemas must be objects — only model refs and inline objects qualify. */
|
|
164
|
+
function outputSchemaExpr(op: OpOperationNode): string | undefined {
|
|
165
|
+
const body = primaryResponse(op)?.bodyType;
|
|
166
|
+
if (!body) return undefined;
|
|
167
|
+
if (body.kind === 'ref') return body.name;
|
|
168
|
+
if (body.kind === 'inlineObject') return renderType(body);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Annotations ────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
const HINT_KEYS = ['readOnlyHint', 'destructiveHint', 'idempotentHint', 'openWorldHint'] as const;
|
|
175
|
+
|
|
176
|
+
function annotationsExpr(cfg: McpConfigNode | undefined): string | undefined {
|
|
177
|
+
if (!cfg) return undefined;
|
|
178
|
+
const parts: string[] = [];
|
|
179
|
+
for (const key of HINT_KEYS) {
|
|
180
|
+
const val = cfg[key];
|
|
181
|
+
if (val !== undefined) parts.push(`${key}: ${val}`);
|
|
182
|
+
}
|
|
183
|
+
return parts.length > 0 ? `{ ${parts.join(', ')} }` : undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── Schema-import collection ───────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
function walkTypeRefs(type: ContractTypeNode, ids: Set<string>, variant: 'input' | 'read', modelsWithInput?: Set<string>): void {
|
|
189
|
+
switch (type.kind) {
|
|
190
|
+
case 'ref':
|
|
191
|
+
ids.add(variant === 'input' ? refSchema(type.name, modelsWithInput) : type.name);
|
|
192
|
+
break;
|
|
193
|
+
case 'array':
|
|
194
|
+
walkTypeRefs(type.item, ids, variant, modelsWithInput);
|
|
195
|
+
break;
|
|
196
|
+
case 'tuple':
|
|
197
|
+
type.items.forEach(t => walkTypeRefs(t, ids, variant, modelsWithInput));
|
|
198
|
+
break;
|
|
199
|
+
case 'record':
|
|
200
|
+
walkTypeRefs(type.key, ids, variant, modelsWithInput);
|
|
201
|
+
walkTypeRefs(type.value, ids, variant, modelsWithInput);
|
|
202
|
+
break;
|
|
203
|
+
case 'union':
|
|
204
|
+
case 'discriminatedUnion':
|
|
205
|
+
case 'intersection':
|
|
206
|
+
type.members.forEach(t => walkTypeRefs(t, ids, variant, modelsWithInput));
|
|
207
|
+
break;
|
|
208
|
+
case 'inlineObject':
|
|
209
|
+
type.fields.forEach(f => walkTypeRefs(f.type, ids, variant, modelsWithInput));
|
|
210
|
+
break;
|
|
211
|
+
case 'lazy':
|
|
212
|
+
walkTypeRefs(type.inner, ids, variant, modelsWithInput);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function walkSourceRefs(src: ParamSource | undefined, ids: Set<string>, modelsWithInput?: Set<string>): void {
|
|
218
|
+
if (!src) return;
|
|
219
|
+
if (src.kind === 'ref') ids.add(refSchema(src.name, modelsWithInput));
|
|
220
|
+
else if (src.kind === 'params') src.nodes.forEach(n => walkTypeRefs(n.type, ids, 'input', modelsWithInput));
|
|
221
|
+
else walkTypeRefs(src.node, ids, 'input', modelsWithInput);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Collect every schema identifier the emitted tools import (input variants + read variants for output). */
|
|
225
|
+
function collectSchemaIds(ops: { route: OpRouteNode; op: OpOperationNode }[], modelsWithInput?: Set<string>): Set<string> {
|
|
226
|
+
const ids = new Set<string>();
|
|
227
|
+
for (const { route, op } of ops) {
|
|
228
|
+
walkSourceRefs(route.params, ids, modelsWithInput);
|
|
229
|
+
const bodies = op.request?.bodies ?? [];
|
|
230
|
+
if (bodies.length === 1 && bodies[0]!.contentType !== 'multipart/form-data') {
|
|
231
|
+
walkTypeRefs(bodies[0]!.bodyType, ids, 'input', modelsWithInput);
|
|
232
|
+
}
|
|
233
|
+
walkSourceRefs(op.query, ids, modelsWithInput);
|
|
234
|
+
walkSourceRefs(op.headers, ids, modelsWithInput);
|
|
235
|
+
|
|
236
|
+
const body = primaryResponse(op)?.bodyType;
|
|
237
|
+
if (body && (body.kind === 'ref' || body.kind === 'inlineObject')) walkTypeRefs(body, ids, 'read');
|
|
238
|
+
}
|
|
239
|
+
return ids;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function schemaImportLines(ids: Set<string>, options: McpCodegenOptions): string[] {
|
|
243
|
+
const lines: string[] = [];
|
|
244
|
+
const { modelOutPaths, outPath } = options;
|
|
245
|
+
if (ids.size === 0) return lines;
|
|
246
|
+
if (modelOutPaths && outPath) {
|
|
247
|
+
const byFile = new Map<string, string[]>();
|
|
248
|
+
const unresolved: string[] = [];
|
|
249
|
+
for (const id of ids) {
|
|
250
|
+
const p = modelOutPaths.get(id);
|
|
251
|
+
if (p) {
|
|
252
|
+
const group = byFile.get(p) ?? [];
|
|
253
|
+
group.push(id);
|
|
254
|
+
byFile.set(p, group);
|
|
255
|
+
} else {
|
|
256
|
+
unresolved.push(id);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
const fromDir = dirname(outPath);
|
|
260
|
+
for (const [file, names] of byFile) {
|
|
261
|
+
let rel = relative(fromDir, file).replace(/\.ts$/, '.js');
|
|
262
|
+
if (!rel.startsWith('.')) rel = './' + rel;
|
|
263
|
+
lines.push(`import { ${names.sort().join(', ')} } from '${rel}';`);
|
|
264
|
+
}
|
|
265
|
+
for (const id of unresolved.sort()) lines.push(`import { ${id} } from './${pascalToDotCase(id)}.js';`);
|
|
266
|
+
} else {
|
|
267
|
+
for (const id of [...ids].sort()) lines.push(`import { ${id} } from './${pascalToDotCase(id)}.js';`);
|
|
268
|
+
}
|
|
269
|
+
return lines;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── Zod scalar helper preludes ─────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
function scalarHelperLines(body: string): string[] {
|
|
275
|
+
const lines: string[] = [];
|
|
276
|
+
if (body.includes('_ZodBinary')) {
|
|
277
|
+
lines.push(`const _ZodBinary = z.custom<Buffer>((val) => Buffer.isBuffer(val), { error: 'Must be binary data' });`);
|
|
278
|
+
}
|
|
279
|
+
if (body.includes('_ZodDatetime')) {
|
|
280
|
+
lines.push(
|
|
281
|
+
`const _ZodDatetime = z.preprocess((val) => typeof val === 'string' ? DateTime.fromISO(val) : val, z.custom<DateTime>((val) => val instanceof DateTime && val.isValid, { message: 'Must be in ISO 8601 format' }));`,
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
if (body.includes('_ZodInterval')) {
|
|
285
|
+
lines.push(
|
|
286
|
+
`const _ZodInterval = z.preprocess((val) => typeof val === 'string' ? Interval.fromISO(val) : val, z.custom<Interval>((val) => val instanceof Interval && val.isValid, { message: 'Must be an ISO 8601 interval' })).transform(val => val.toISO()!);`,
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
if (body.includes('_ZodJson')) {
|
|
290
|
+
lines.push(`type _JsonValue = string | number | boolean | null | _JsonValue[] | { [key: string]: _JsonValue };`);
|
|
291
|
+
lines.push(
|
|
292
|
+
`const _ZodJson: z.ZodType<_JsonValue> = z.lazy(() => z.union([z.string(), z.number(), z.boolean(), z.null(), z.array(_ZodJson), z.record(z.string(), _ZodJson)]));`,
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
return lines;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// ─── Per-tool codegen ─────────────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
interface ToolPlan {
|
|
301
|
+
route: OpRouteNode;
|
|
302
|
+
op: OpOperationNode;
|
|
303
|
+
toolName: string;
|
|
304
|
+
className: string;
|
|
305
|
+
argsConstName: string;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function planTools(root: OpRootNode, includeInternal: boolean): ToolPlan[] {
|
|
309
|
+
const plans: ToolPlan[] = [];
|
|
310
|
+
for (const route of root.routes) {
|
|
311
|
+
for (const op of route.operations) {
|
|
312
|
+
if (!op.mcp) continue;
|
|
313
|
+
if (!includeInternal && resolveModifiers(route, op).includes('internal')) continue;
|
|
314
|
+
const toolName = deriveToolName(op, route);
|
|
315
|
+
const className = deriveToolClassName(toolName);
|
|
316
|
+
plans.push({ route, op, toolName, className, argsConstName: `${toPascal(toolName)}Args` });
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return plans;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function renderToolClass(plan: ToolPlan, file: string, options: McpCodegenOptions): string[] {
|
|
323
|
+
const { route, op, toolName, className, argsConstName } = plan;
|
|
324
|
+
const cfg = mcpConfig(op);
|
|
325
|
+
const lines: string[] = [];
|
|
326
|
+
|
|
327
|
+
// JSDoc source link
|
|
328
|
+
const relFile = options.outPath ? relative(dirname(options.outPath), file) : file;
|
|
329
|
+
lines.push('/**');
|
|
330
|
+
lines.push(` * from [${basename(file)}](file://./${relFile}#L${op.loc.line})`);
|
|
331
|
+
lines.push(' */');
|
|
332
|
+
|
|
333
|
+
lines.push('@Injectable()');
|
|
334
|
+
lines.push(`export class ${className} implements McpToolHandler {`);
|
|
335
|
+
|
|
336
|
+
// definition
|
|
337
|
+
lines.push(' readonly definition: Tool = {');
|
|
338
|
+
lines.push(` name: '${escapeSingleQuoted(toolName)}',`);
|
|
339
|
+
if (cfg?.title) lines.push(` title: '${escapeSingleQuoted(cfg.title)}',`);
|
|
340
|
+
const desc = cfg?.description ?? op.description ?? route.description;
|
|
341
|
+
if (desc) lines.push(` description: '${escapeSingleQuoted(desc)}',`);
|
|
342
|
+
lines.push(` inputSchema: z.toJSONSchema(${argsConstName}, { unrepresentable: 'any' }) as Tool['inputSchema'],`);
|
|
343
|
+
const outExpr = outputSchemaExpr(op);
|
|
344
|
+
if (outExpr) lines.push(` outputSchema: z.toJSONSchema(${outExpr}, { unrepresentable: 'any' }) as Tool['outputSchema'],`);
|
|
345
|
+
const annotations = annotationsExpr(cfg);
|
|
346
|
+
if (annotations) lines.push(` annotations: ${annotations},`);
|
|
347
|
+
lines.push(' };');
|
|
348
|
+
lines.push('');
|
|
349
|
+
|
|
350
|
+
// constructor injects the operation's service
|
|
351
|
+
const service = inferService(op, route, file);
|
|
352
|
+
lines.push(` constructor(private readonly service: ${service.className}) {}`);
|
|
353
|
+
lines.push('');
|
|
354
|
+
|
|
355
|
+
// handle
|
|
356
|
+
const props = buildArgsProps(route, op, options.modelsWithInput);
|
|
357
|
+
const destructure = props.map(p => p.key);
|
|
358
|
+
const callArgs = buildArgs(route, op);
|
|
359
|
+
const isVoid = !primaryResponse(op)?.bodyType;
|
|
360
|
+
const structured = !!outExpr;
|
|
361
|
+
|
|
362
|
+
lines.push(' async handle(args: Record<string, unknown>, _context: McpToolContext): Promise<CallToolResult> {');
|
|
363
|
+
if (destructure.length > 0) {
|
|
364
|
+
lines.push(` const { ${destructure.join(', ')} } = await parseAndValidate(args, ${argsConstName});`);
|
|
365
|
+
}
|
|
366
|
+
if (isVoid) {
|
|
367
|
+
lines.push(` await this.service.${service.methodName}(${callArgs});`);
|
|
368
|
+
lines.push(` return { content: [{ type: 'text', text: 'OK' }] };`);
|
|
369
|
+
} else {
|
|
370
|
+
lines.push(` const result = await this.service.${service.methodName}(${callArgs});`);
|
|
371
|
+
if (structured) {
|
|
372
|
+
lines.push(` return { content: [{ type: 'text', text: JSON.stringify(result) }], structuredContent: result };`);
|
|
373
|
+
} else {
|
|
374
|
+
lines.push(` return { content: [{ type: 'text', text: JSON.stringify(result) }] };`);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
lines.push(' }');
|
|
378
|
+
lines.push('}');
|
|
379
|
+
|
|
380
|
+
return lines;
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// ─── Public entry points ────────────────────────────────────────────────────
|
|
384
|
+
|
|
385
|
+
/** The exported register-fn name for an op-root file, e.g. `payments.op` → `registerPaymentsMcpTools`. */
|
|
386
|
+
export function deriveMcpRegisterFnName(file: string): string {
|
|
387
|
+
return `register${deriveBaseName(file)}McpTools`;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** Generate one `<filename>.mcp.ts` for an op-root: tool handler classes + a per-file register fn. */
|
|
391
|
+
export function generateMcpFile(root: OpRootNode, options: McpCodegenOptions = {}): string {
|
|
392
|
+
const includeInternal = options.includeInternal ?? false;
|
|
393
|
+
const plans = planTools(root, includeInternal);
|
|
394
|
+
|
|
395
|
+
// Args schema consts (also drive the JSON-Schema definitions).
|
|
396
|
+
const argsConsts = plans.map(p => `const ${p.argsConstName} = ${argsSchemaExpr(buildArgsProps(p.route, p.op, options.modelsWithInput))};`);
|
|
397
|
+
|
|
398
|
+
// Tool classes.
|
|
399
|
+
const classes = plans.map(p => renderToolClass(p, root.file, options).join('\n'));
|
|
400
|
+
|
|
401
|
+
// Per-file register fn.
|
|
402
|
+
const registerFn: string[] = [];
|
|
403
|
+
registerFn.push(`/** Add this file's tools to the shared catalog. */`);
|
|
404
|
+
registerFn.push(`export function ${deriveMcpRegisterFnName(root.file)}(map: McpToolHandlerMap, container: Container): void {`);
|
|
405
|
+
for (const p of plans) registerFn.push(` map.set('${escapeSingleQuoted(p.toolName)}', container.get(${p.className}));`);
|
|
406
|
+
registerFn.push('}');
|
|
407
|
+
|
|
408
|
+
const bodyCore = [argsConsts.join('\n'), classes.join('\n\n'), registerFn.join('\n')].filter(Boolean).join('\n\n');
|
|
409
|
+
|
|
410
|
+
// Zod scalar helper consts (must precede the args consts that reference them).
|
|
411
|
+
const helperConsts = scalarHelperLines(bodyCore);
|
|
412
|
+
const bodyWithHelpers = [helperConsts.join('\n'), bodyCore].filter(Boolean).join('\n\n');
|
|
413
|
+
|
|
414
|
+
// ── Imports ──
|
|
415
|
+
const needsParseAndValidate = plans.some(p => buildArgsProps(p.route, p.op, options.modelsWithInput).length > 0);
|
|
416
|
+
const imports: string[] = [];
|
|
417
|
+
imports.push(`import { Injectable, type Container } from 'injectkit';`);
|
|
418
|
+
imports.push(`import { z } from 'zod';`);
|
|
419
|
+
|
|
420
|
+
const luxon: string[] = [];
|
|
421
|
+
if (/\bDateTime\b/.test(bodyWithHelpers)) luxon.push('DateTime');
|
|
422
|
+
if (/\bInterval\b/.test(bodyWithHelpers)) luxon.push('Interval');
|
|
423
|
+
if (/\bDuration\b/.test(bodyWithHelpers)) luxon.push('Duration');
|
|
424
|
+
if (luxon.length > 0) imports.push(`import { ${luxon.join(', ')} } from 'luxon';`);
|
|
425
|
+
|
|
426
|
+
imports.push(`import type { CallToolResult, Tool } from '@modelcontextprotocol/sdk/types.js';`);
|
|
427
|
+
imports.push(`import type { McpToolHandler, McpToolHandlerMap, McpToolContext } from '@maroonedsoftware/mcp';`);
|
|
428
|
+
if (needsParseAndValidate) imports.push(`import { parseAndValidate } from '@maroonedsoftware/zod';`);
|
|
429
|
+
|
|
430
|
+
// Service imports (one per distinct service used by the emitted tools).
|
|
431
|
+
const serviceModules = new Map<string, string>();
|
|
432
|
+
for (const p of plans) {
|
|
433
|
+
const svc = inferService(p.op, p.route, root.file).className;
|
|
434
|
+
if (!serviceModules.has(svc)) {
|
|
435
|
+
serviceModules.set(svc, root.services?.[svc] ?? root.meta[svc] ?? deriveModulePath(svc, options.servicePathTemplate));
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
for (const [svc, mod] of [...serviceModules.entries()].sort(([a], [b]) => a.localeCompare(b))) {
|
|
439
|
+
imports.push(`import { ${svc} } from '${mod}';`);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Schema imports.
|
|
443
|
+
imports.push(...schemaImportLines(collectSchemaIds(plans, options.modelsWithInput), options));
|
|
444
|
+
|
|
445
|
+
const relFile = options.outPath ? relative(dirname(options.outPath), root.file) : root.file;
|
|
446
|
+
const header = `// Auto-generated MCP tools\n// generated from [${basename(root.file)}](file://./${relFile})`;
|
|
447
|
+
|
|
448
|
+
return `${header}\n${imports.join('\n')}\n\n${bodyWithHelpers}\n`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** One entry per emitted `<filename>.mcp.ts` for the aggregator to import. */
|
|
452
|
+
export interface McpAggregatorEntry {
|
|
453
|
+
/** The file's exported register fn name, e.g. `registerPaymentsMcpTools`. */
|
|
454
|
+
registerFn: string;
|
|
455
|
+
/** Module specifier for the file, relative to the aggregator and `.js`-suffixed. */
|
|
456
|
+
importPath: string;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** Generate the aggregator `mcp.tools.ts` that assembles the single McpToolHandlerMap. */
|
|
460
|
+
export function generateMcpAggregator(entries: McpAggregatorEntry[]): string {
|
|
461
|
+
const sorted = [...entries].sort((a, b) => a.registerFn.localeCompare(b.registerFn));
|
|
462
|
+
const lines: string[] = [];
|
|
463
|
+
lines.push(`import { type Container } from 'injectkit';`);
|
|
464
|
+
lines.push(`import { McpToolHandlerMap } from '@maroonedsoftware/mcp';`);
|
|
465
|
+
for (const e of sorted) lines.push(`import { ${e.registerFn} } from '${e.importPath}';`);
|
|
466
|
+
lines.push('');
|
|
467
|
+
lines.push('/** Build + register the MCP tool catalog. Call once at startup. */');
|
|
468
|
+
lines.push('export function registerMcpTools(container: Container): McpToolHandlerMap {');
|
|
469
|
+
lines.push(' const map = new McpToolHandlerMap();');
|
|
470
|
+
for (const e of sorted) lines.push(` ${e.registerFn}(map, container);`);
|
|
471
|
+
lines.push(' container.register(McpToolHandlerMap, { useValue: map });');
|
|
472
|
+
lines.push(' return map;');
|
|
473
|
+
lines.push('}');
|
|
474
|
+
return lines.join('\n') + '\n';
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Generate the optional `mcp.router.ts` — the standard ServerKit route wiring for the dispatcher. */
|
|
478
|
+
export function generateMcpRouter(options: { path?: string } = {}): string {
|
|
479
|
+
const path = options.path ?? '/mcp';
|
|
480
|
+
return `import { ServerKitRouter, requireSignature } from '@maroonedsoftware/koa';
|
|
481
|
+
import { McpDispatcher, createMcpRequestContext, MCP_AUTH_POLICY } from '@maroonedsoftware/mcp';
|
|
482
|
+
|
|
483
|
+
/** Mount the MCP endpoint onto a ServerKit router. Call \`registerMcpTools(container)\` at startup. */
|
|
484
|
+
export function mountMcp(router: ReturnType<typeof ServerKitRouter>): void {
|
|
485
|
+
router.post('${path}', requireSignature('mcp', { policy: MCP_AUTH_POLICY }), async (ctx) => {
|
|
486
|
+
const dispatcher = ctx.container.get(McpDispatcher);
|
|
487
|
+
const context = createMcpRequestContext({ requestId: ctx.requestId, logger: ctx.logger });
|
|
488
|
+
if (dispatcher.sessionMode === 'stateful') {
|
|
489
|
+
ctx.respond = false;
|
|
490
|
+
await dispatcher.dispatchStateful(
|
|
491
|
+
{ req: ctx.req, res: ctx.res, body: ctx.request.body, sessionId: ctx.get('mcp-session-id') },
|
|
492
|
+
context,
|
|
493
|
+
);
|
|
494
|
+
} else {
|
|
495
|
+
const response = await dispatcher.dispatch(JSON.parse(ctx.rawBody), context);
|
|
496
|
+
if (response) ctx.body = response;
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
`;
|
|
501
|
+
}
|
package/src/codegen-operation.ts
CHANGED
|
@@ -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;
|
|
@@ -267,7 +268,7 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
267
268
|
}
|
|
268
269
|
const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
|
|
269
270
|
|
|
270
|
-
lines.push(`${deriveRouterName(file)}.${method}('${path}'${middlewareStr} async
|
|
271
|
+
lines.push(`${deriveRouterName(file)}.${method}('${path}'${middlewareStr} async ctx => {`);
|
|
271
272
|
|
|
272
273
|
// Params / query / headers validation (request-side — use Input variants)
|
|
273
274
|
lines.push(...generateParamValidation(route.params, 'ctx.params', 'params', route.paramsMode ?? 'strict', '', modelsWithInput));
|
|
@@ -368,7 +369,16 @@ function generateHandler(route: OpRouteNode, op: OpOperationNode, root: OpRootNo
|
|
|
368
369
|
|
|
369
370
|
// ─── Inference helpers ─────────────────────────────────────────────────────
|
|
370
371
|
|
|
371
|
-
|
|
372
|
+
/**
|
|
373
|
+
* Resolve the service class and method a handler should delegate to.
|
|
374
|
+
*
|
|
375
|
+
* Uses the operation's explicit `service: Class.method` declaration when present; otherwise derives
|
|
376
|
+
* the class from the contract file name (`ledger.categories.ck` → `LedgerCategoriesService`) and the
|
|
377
|
+
* method from the HTTP verb and whether the path carries a parameter (`get` → `list` / `getById`).
|
|
378
|
+
*
|
|
379
|
+
* @param file Path of the `.ck` file the operation came from.
|
|
380
|
+
*/
|
|
381
|
+
export function inferService(op: OpOperationNode, route: OpRouteNode, file: string): { className: string; methodName: string } {
|
|
372
382
|
// If explicitly declared: service: ServiceClass.methodName
|
|
373
383
|
if (op.service) {
|
|
374
384
|
const [cls = '', method] = op.service.split('.');
|
|
@@ -400,7 +410,16 @@ function inferMethodName(method: string, path: string): string {
|
|
|
400
410
|
}
|
|
401
411
|
}
|
|
402
412
|
|
|
403
|
-
|
|
413
|
+
/**
|
|
414
|
+
* Build the comma-separated argument list passed to the service method in a generated handler.
|
|
415
|
+
*
|
|
416
|
+
* Order is params, body, query, headers. Inline path params are spread as individual identifiers;
|
|
417
|
+
* a referenced/compound params type is passed as a single `params` object. A lone
|
|
418
|
+
* `multipart/form-data` request body is passed as `multipartBody` rather than `body`.
|
|
419
|
+
*
|
|
420
|
+
* @returns The rendered argument list, or an empty string when the method takes no arguments.
|
|
421
|
+
*/
|
|
422
|
+
export function buildArgs(route: OpRouteNode, op: OpOperationNode): string {
|
|
404
423
|
const args: string[] = [];
|
|
405
424
|
// Path params: spread individually (inline) or pass 'params' object (type-ref/ContractTypeNode)
|
|
406
425
|
if (route.params) {
|
|
@@ -792,7 +811,13 @@ function isValidIdentifier(name: string): boolean {
|
|
|
792
811
|
|
|
793
812
|
// ─── Naming conventions ────────────────────────────────────────────────────
|
|
794
813
|
|
|
795
|
-
|
|
814
|
+
/**
|
|
815
|
+
* Derive the PascalCase base name used for router, service, and type names from a contract file path.
|
|
816
|
+
*
|
|
817
|
+
* Strips directories and the `.op`/`.ck` extension, then PascalCases each dot-separated segment
|
|
818
|
+
* (`contracts/ledger.categories.ck` → `LedgerCategories`). Falls back to `Resource` for an empty path.
|
|
819
|
+
*/
|
|
820
|
+
export function deriveBaseName(file: string): string {
|
|
796
821
|
const base =
|
|
797
822
|
file
|
|
798
823
|
.split('/')
|
|
@@ -809,7 +834,16 @@ function deriveRouterName(file: string): string {
|
|
|
809
834
|
return `${deriveBaseName(file)}Router`;
|
|
810
835
|
}
|
|
811
836
|
|
|
812
|
-
|
|
837
|
+
/**
|
|
838
|
+
* Resolve the import specifier for a service class.
|
|
839
|
+
*
|
|
840
|
+
* Drops the trailing `Service` suffix and kebab-cases the remainder, then applies `template` if given
|
|
841
|
+
* (`{name}` → `Ledger`, `{kebab}` → `ledger`). Without a template, defaults to
|
|
842
|
+
* `#modules/<kebab>/<kebab>.service.js`.
|
|
843
|
+
*
|
|
844
|
+
* @param template Optional `servicePathTemplate` from the plugin config.
|
|
845
|
+
*/
|
|
846
|
+
export function deriveModulePath(serviceName: string, template?: string): string {
|
|
813
847
|
// LedgerService -> #modules/ledger/ledger.service.js
|
|
814
848
|
const base = serviceName.replace(/Service$/, '');
|
|
815
849
|
const kebab = base.replace(/([A-Z])/g, m => `-${m.toLowerCase()}`).replace(/^-/, '');
|