@contractkit/plugin-typescript 0.28.0 → 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 +22 -19
- package/CHANGELOG.md +14 -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-sdk.d.ts.map +1 -1
- package/dist/index.d.ts +27 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +807 -243
- package/dist/index.js.map +1 -1
- package/dist/path-utils.d.ts.map +1 -1
- package/dist/ts-render.d.ts +6 -0
- package/dist/ts-render.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/codegen-contract.ts +7 -4
- package/src/codegen-mcp.ts +501 -0
- package/src/codegen-operation.ts +43 -9
- package/src/codegen-plain-types.ts +17 -14
- package/src/codegen-sdk.ts +5 -4
- package/src/index.ts +154 -0
- package/src/path-utils.ts +37 -20
- package/src/ts-render.ts +21 -6
- package/tests/codegen-contract.test.ts +4 -0
- package/tests/codegen-mcp.test.ts +246 -0
- package/tests/codegen-operation.test.ts +18 -0
- package/tests/codegen-sdk.test.ts +8 -0
- package/tests/escaping-security.test.ts +143 -0
- package/tests/pipeline.test.ts +59 -0
- package/coverage/base.css +0 -224
- package/coverage/block-navigation.js +0 -87
- package/coverage/clover.xml +0 -2213
- package/coverage/coverage-final.json +0 -9
- package/coverage/favicon.png +0 -0
- package/coverage/index.html +0 -131
- package/coverage/prettify.css +0 -1
- package/coverage/prettify.js +0 -2
- package/coverage/sort-arrow-sprite.png +0 -0
- package/coverage/sorter.js +0 -210
- package/coverage/src/codegen-contract.ts.html +0 -3661
- package/coverage/src/codegen-operation.ts.html +0 -2584
- package/coverage/src/codegen-plain-types.ts.html +0 -997
- package/coverage/src/codegen-sdk.ts.html +0 -3901
- package/coverage/src/index.html +0 -206
- package/coverage/src/index.ts.html +0 -2761
- package/coverage/src/path-utils.ts.html +0 -745
- package/coverage/src/ts-render.ts.html +0 -592
- package/coverage/tests/helpers.ts.html +0 -826
- package/coverage/tests/index.html +0 -116
package/dist/path-utils.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../src/path-utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGtE,eAAO,MAAM,eAAe,QAAY,CAAC;
|
|
1
|
+
{"version":3,"file":"path-utils.d.ts","sourceRoot":"","sources":["../src/path-utils.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,gBAAgB,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAGtE,eAAO,MAAM,eAAe,QAAY,CAAC;AAmBzC,wBAAgB,eAAe,CAAC,QAAQ,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAAG,MAAM,CAEtF;AAED,wBAAgB,gBAAgB,CAAC,CAAC,EAAE,MAAM,GAAG,OAAO,CAGnD;AAED,wBAAgB,SAAS,CAAC,KAAK,EAAE,MAAM,EAAE,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM,CAclE;AAID,wBAAgB,gBAAgB,CAC5B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAiBR;AAED,wBAAgB,sBAAsB,CAClC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GAAG,SAAS,EAC1B,aAAa,EAAE,MAAM,EACrB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,CAER;AAID,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,YAAY,EAAE,MAAM,GAAG,SAAS,EAChC,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAkBf;AAED;;;;;;;;;GASG;AACH,wBAAgB,2BAA2B,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,CAoBnH;AAED,wBAAgB,qBAAqB,CACjC,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,EAClB,UAAU,EAAE,MAAM,EAClB,IAAI,GAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAM,GAClC,MAAM,GAAG,IAAI,CAef;AAED,wBAAgB,mBAAmB,CAAC,aAAa,EAAE,MAAM,EAAE,GAAG;IAAE,OAAO,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,EAAE,CAiBnG;AAED,wBAAgB,6BAA6B,CACzC,MAAM,EAAE,UAAU,EAAE,EACpB,YAAY,EAAE,gBAAgB,EAAE,EAChC,eAAe,EAAE,GAAG,CAAC,MAAM,CAAC,EAC5B,gBAAgB,GAAE,GAAG,CAAC,MAAM,CAAa,GAC1C,GAAG,CAAC,MAAM,CAAC,GAAG,IAAI,CA0CpB"}
|
package/dist/ts-render.d.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import type { ContractTypeNode } from '@contractkit/core';
|
|
2
2
|
export declare const JSON_VALUE_TYPE_DECL = "export type JsonValue = string | number | boolean | null | JsonValue[] | { [key: string]: JsonValue };";
|
|
3
3
|
export declare function quoteKey(name: string): string;
|
|
4
|
+
/** Escape text for safe inclusion inside a JSDoc block comment: neutralize the
|
|
5
|
+
* block-comment terminator sequence and split embedded newlines into separate
|
|
6
|
+
* ` * ` continuation lines. Returns the content lines (WITHOUT a leading prefix). */
|
|
7
|
+
export declare function escapeJsDocLines(text: string): string[];
|
|
8
|
+
/** Escape a string for inclusion inside a single-quoted TypeScript string literal. */
|
|
9
|
+
export declare function escapeSingleQuoted(s: string): string;
|
|
4
10
|
/** Convert an HTTP header name (e.g. `preference-applied`, `X-Request-ID`, `ETag`) to camelCase for use as a JS property. */
|
|
5
11
|
export declare function headerNameToProperty(name: string): string;
|
|
6
12
|
export declare function renderTsType(type: ContractTypeNode): string;
|
package/dist/ts-render.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,
|
|
1
|
+
{"version":3,"file":"ts-render.d.ts","sourceRoot":"","sources":["../src/ts-render.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,gBAAgB,EAA6B,MAAM,mBAAmB,CAAC;AAErF,eAAO,MAAM,oBAAoB,2GAA2G,CAAC;AAE7I,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE7C;AAED;;sFAEsF;AACtF,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,EAAE,CAEvD;AAED,sFAAsF;AACtF,wBAAgB,kBAAkB,CAAC,CAAC,EAAE,MAAM,GAAG,MAAM,CAEpD;AAED,6HAA6H;AAC7H,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAQzD;AAID,wBAAgB,YAAY,CAAC,IAAI,EAAE,gBAAgB,GAAG,MAAM,CAoC3D;AA+CD;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,IAAI,EAAE,gBAAgB,EAAE,eAAe,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2B/F;AAED;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,gBAAgB,EAAE,gBAAgB,CAAC,EAAE,GAAG,CAAC,MAAM,CAAC,GAAG,MAAM,CA2BjG"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@contractkit/plugin-typescript",
|
|
3
|
-
"version": "0.28.
|
|
3
|
+
"version": "0.28.2",
|
|
4
4
|
"description": "ContractKit built-in plugin: TypeScript codegen (SDK clients, Koa routers, Zod schemas, plain types)",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Marooned Software",
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
".": "./dist/index.js"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
-
"@contractkit/core": "0.
|
|
29
|
+
"@contractkit/core": "0.23.0"
|
|
30
30
|
},
|
|
31
31
|
"devDependencies": {
|
|
32
32
|
"@repo/config-eslint": "0.3.1",
|
package/src/codegen-contract.ts
CHANGED
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
computeModelsWithOutput as ckComputeModelsWithOutput,
|
|
22
22
|
collectExternalOutputRefs as ckCollectExternalOutputRefs,
|
|
23
23
|
} from '@contractkit/core';
|
|
24
|
+
import { escapeJsDocLines } from './ts-render.js';
|
|
24
25
|
|
|
25
26
|
/**
|
|
26
27
|
* Maps a ContractKit object mode to its Zod constructor name.
|
|
@@ -108,7 +109,7 @@ function generateComments(model: ModelNode, outPath?: string): string[] {
|
|
|
108
109
|
lines.push(` * @deprecated`);
|
|
109
110
|
}
|
|
110
111
|
if (model.description) {
|
|
111
|
-
lines.push(` * ${
|
|
112
|
+
for (const l of escapeJsDocLines(model.description)) lines.push(` * ${l}`);
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
const relPath = outPath ? relative(dirname(outPath), model.loc.file) : model.loc.file;
|
|
@@ -664,8 +665,10 @@ function renderScalar(s: ScalarTypeNode): string {
|
|
|
664
665
|
return '_ZodBinary';
|
|
665
666
|
case 'json':
|
|
666
667
|
return '_ZodJson';
|
|
667
|
-
default:
|
|
668
|
-
|
|
668
|
+
default: {
|
|
669
|
+
const _exhaustive: never = s.name;
|
|
670
|
+
throw new Error(`plugin-typescript: unmapped scalar '${String(_exhaustive)}' — add a case`);
|
|
671
|
+
}
|
|
669
672
|
}
|
|
670
673
|
}
|
|
671
674
|
|
|
@@ -685,7 +688,7 @@ function renderRecord(r: RecordTypeNode): string {
|
|
|
685
688
|
}
|
|
686
689
|
|
|
687
690
|
function renderEnum(e: EnumTypeNode): string {
|
|
688
|
-
const vals = e.values.map(v => `"${v}"`).join(', ');
|
|
691
|
+
const vals = e.values.map(v => `"${escapeString(v)}"`).join(', ');
|
|
689
692
|
return `z.enum([${vals}])`;
|
|
690
693
|
}
|
|
691
694
|
|
|
@@ -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
|
+
}
|