@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/src/index.ts
CHANGED
|
@@ -41,6 +41,7 @@ import {
|
|
|
41
41
|
type SdkScaffoldDeps,
|
|
42
42
|
} from './codegen-sdk.js';
|
|
43
43
|
import { generatePlainTypes } from './codegen-plain-types.js';
|
|
44
|
+
import { generateMcpFile, generateMcpAggregator, generateMcpRouter, hasMcpOperations, deriveMcpRegisterFnName } from './codegen-mcp.js';
|
|
44
45
|
import {
|
|
45
46
|
TEMPLATE_VAR_RE,
|
|
46
47
|
resolveTemplate,
|
|
@@ -104,11 +105,39 @@ export interface TypesConfig {
|
|
|
104
105
|
output?: string;
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
export interface McpConfig {
|
|
109
|
+
/** Directory (relative to rootDir) where MCP files are written. Default: rootDir. */
|
|
110
|
+
baseDir?: string;
|
|
111
|
+
output?: {
|
|
112
|
+
/** Path template for per-op-file tool handlers. Supports {filename}, {dir}, {area}. Default `{filename}.mcp.ts`. */
|
|
113
|
+
tools?: string;
|
|
114
|
+
/** Path (or template) for the aggregator that assembles the McpToolHandlerMap. Default `mcp.tools.ts`. */
|
|
115
|
+
index?: string;
|
|
116
|
+
/** Path (or template) for the optional POST /mcp route file. Default `mcp.router.ts`. */
|
|
117
|
+
router?: string;
|
|
118
|
+
/**
|
|
119
|
+
* Path template for the model **Zod schema** files the tools import (for arg validation and
|
|
120
|
+
* `z.toJSONSchema`). When omitted, falls back to the `server` sub-config's `output.types`
|
|
121
|
+
* (if `server.zod`) or the `zod` sub-config's output. Tools require Zod schemas, not plain types.
|
|
122
|
+
*/
|
|
123
|
+
types?: string;
|
|
124
|
+
};
|
|
125
|
+
/** Emit the `mcp.router.ts` route boilerplate. Default true. */
|
|
126
|
+
emitRouter?: boolean;
|
|
127
|
+
/** Mount path used in the emitted router. Default `/mcp`. */
|
|
128
|
+
path?: string;
|
|
129
|
+
/** Import path template for service implementations (same semantics as ServerConfig). */
|
|
130
|
+
servicePathTemplate?: string;
|
|
131
|
+
/** Whether to expose operations marked `internal` as MCP tools. Default false. */
|
|
132
|
+
includeInternal?: boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
107
135
|
export interface TypescriptPluginConfig {
|
|
108
136
|
server?: ServerConfig;
|
|
109
137
|
sdk?: SdkConfig;
|
|
110
138
|
zod?: ZodConfig;
|
|
111
139
|
types?: TypesConfig;
|
|
140
|
+
mcp?: McpConfig;
|
|
112
141
|
}
|
|
113
142
|
|
|
114
143
|
// ─── Caching constants ─────────────────────────────────────────────────────
|
|
@@ -165,6 +194,7 @@ async function runTypescriptCodegen(
|
|
|
165
194
|
if (config.sdk) collectSdkOutput(config.sdk, rootDir, inputs, units, globalFiles);
|
|
166
195
|
if (config.zod) collectZodOutput(config.zod, rootDir, inputs, units);
|
|
167
196
|
if (config.types) collectTypesOutput(config.types, rootDir, inputs, units);
|
|
197
|
+
if (config.mcp) collectMcpOutput(config.mcp, config, rootDir, inputs, units, globalFiles);
|
|
168
198
|
|
|
169
199
|
const result = runIncrementalCodegen({
|
|
170
200
|
codegenVersion: TYPESCRIPT_CODEGEN_VERSION,
|
|
@@ -838,6 +868,130 @@ function collectTypesOutput(
|
|
|
838
868
|
}
|
|
839
869
|
}
|
|
840
870
|
|
|
871
|
+
// ─── MCP sub-generator ─────────────────────────────────────────────────────
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* Resolve where the model **Zod schema** files live so the MCP tools can import them (for arg
|
|
875
|
+
* validation + `z.toJSONSchema`). Precedence: explicit `mcp.output.types` → the `server` sub-config's
|
|
876
|
+
* `output.types` (only when `server.zod`) → the `zod` sub-config's output. Returns an empty map when
|
|
877
|
+
* none resolve (imports then fall back to a colocated `./<name>.js` guess).
|
|
878
|
+
*/
|
|
879
|
+
function resolveMcpModelOutPaths(
|
|
880
|
+
config: TypescriptPluginConfig,
|
|
881
|
+
rootDir: string,
|
|
882
|
+
contractRoots: readonly ContractRootNode[],
|
|
883
|
+
commonRoot: string,
|
|
884
|
+
modelsWithInput: Set<string>,
|
|
885
|
+
modelsWithOutput: Set<string>,
|
|
886
|
+
): Map<string, string> {
|
|
887
|
+
const map = new Map<string, string>();
|
|
888
|
+
let base: string;
|
|
889
|
+
let template: string | undefined;
|
|
890
|
+
let suffix: string;
|
|
891
|
+
if (config.mcp?.output?.types) {
|
|
892
|
+
base = resolve(rootDir, config.mcp.baseDir ?? '.');
|
|
893
|
+
template = config.mcp.output.types;
|
|
894
|
+
suffix = '.ts';
|
|
895
|
+
} else if (config.server?.zod && config.server.output?.types) {
|
|
896
|
+
base = resolve(rootDir, config.server.baseDir ?? '.');
|
|
897
|
+
template = config.server.output.types;
|
|
898
|
+
suffix = '.ts';
|
|
899
|
+
} else if (config.zod) {
|
|
900
|
+
base = resolve(rootDir, config.zod.baseDir ?? '.');
|
|
901
|
+
template = config.zod.output;
|
|
902
|
+
suffix = '.schema.ts';
|
|
903
|
+
} else {
|
|
904
|
+
return map;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
for (const ast of contractRoots) {
|
|
908
|
+
const outPath = computeContractOutPath(ast.file, base, template, suffix, commonRoot, ast.meta);
|
|
909
|
+
for (const model of ast.models) {
|
|
910
|
+
map.set(model.name, outPath);
|
|
911
|
+
if (modelsWithInput.has(model.name)) map.set(`${model.name}Input`, outPath);
|
|
912
|
+
if (modelsWithOutput.has(model.name)) map.set(`${model.name}Output`, outPath);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
return map;
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function collectMcpOutput(
|
|
919
|
+
config: McpConfig,
|
|
920
|
+
fullConfig: TypescriptPluginConfig,
|
|
921
|
+
rootDir: string,
|
|
922
|
+
inputs: Parameters<NonNullable<ContractKitPlugin['generateTargets']>>[0],
|
|
923
|
+
units: IncrementalUnit[],
|
|
924
|
+
globalFiles: IncrementalOutputFile[],
|
|
925
|
+
): void {
|
|
926
|
+
const mcpBase = resolve(rootDir, config.baseDir ?? '.');
|
|
927
|
+
const modelsWithInput = inputs.modelsWithInput as Set<string>;
|
|
928
|
+
const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
|
|
929
|
+
const modelMap = buildModelMap(inputs.contractRoots);
|
|
930
|
+
const allFiles = [...inputs.contractRoots.map(r => r.file), ...inputs.opRoots.map(r => r.file)];
|
|
931
|
+
const commonRoot = commonDir(allFiles, rootDir);
|
|
932
|
+
const subConfigKey = stableSubConfig(config);
|
|
933
|
+
const includeInternal = config.includeInternal ?? false;
|
|
934
|
+
|
|
935
|
+
const modelOutPaths = resolveMcpModelOutPaths(fullConfig, rootDir, inputs.contractRoots, commonRoot, modelsWithInput, modelsWithOutput);
|
|
936
|
+
|
|
937
|
+
// ── Per-op-root tool-handler units (only files with MCP-exposed ops) ──
|
|
938
|
+
const entries: { outPath: string; registerFn: string }[] = [];
|
|
939
|
+
for (const ast of inputs.opRoots) {
|
|
940
|
+
if (!hasMcpOperations(ast, includeInternal)) continue;
|
|
941
|
+
const outPath = computeOpOutPath(ast.file, mcpBase, config.output?.tools, '.mcp.ts', commonRoot, ast.meta);
|
|
942
|
+
const refs = collectOpRootRefs(ast, modelMap);
|
|
943
|
+
const fingerprint = hashFingerprint({
|
|
944
|
+
kind: 'mcp-tools',
|
|
945
|
+
v: TYPESCRIPT_CODEGEN_VERSION,
|
|
946
|
+
outPath,
|
|
947
|
+
root: ast,
|
|
948
|
+
outPathSlice: sliceOutPathMap(refs, modelOutPaths, modelsWithInput, modelsWithOutput),
|
|
949
|
+
modelsWithInput: sliceModelSet(refs, new Set(), modelsWithInput),
|
|
950
|
+
modelsWithOutput: sliceModelSet(refs, new Set(), modelsWithOutput),
|
|
951
|
+
servicePathTemplate: config.servicePathTemplate ?? null,
|
|
952
|
+
includeInternal,
|
|
953
|
+
sub: subConfigKey,
|
|
954
|
+
});
|
|
955
|
+
units.push({
|
|
956
|
+
key: `mcp-tools::${outPath}`,
|
|
957
|
+
fingerprint,
|
|
958
|
+
render: () => [
|
|
959
|
+
{
|
|
960
|
+
relativePath: outPath,
|
|
961
|
+
content: generateMcpFile(ast, {
|
|
962
|
+
outPath,
|
|
963
|
+
modelOutPaths,
|
|
964
|
+
modelsWithInput,
|
|
965
|
+
modelsWithOutput,
|
|
966
|
+
servicePathTemplate: config.servicePathTemplate,
|
|
967
|
+
includeInternal,
|
|
968
|
+
}),
|
|
969
|
+
},
|
|
970
|
+
],
|
|
971
|
+
});
|
|
972
|
+
entries.push({ outPath, registerFn: deriveMcpRegisterFnName(ast.file) });
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
if (entries.length === 0) return;
|
|
976
|
+
|
|
977
|
+
// ── Aggregator (global) ──
|
|
978
|
+
const indexPath = join(mcpBase, config.output?.index ?? 'mcp.tools.ts');
|
|
979
|
+
const aggregatorEntries = entries
|
|
980
|
+
.map(e => {
|
|
981
|
+
let rel = relative(dirname(indexPath), e.outPath).replace(/\.ts$/, '.js');
|
|
982
|
+
if (!rel.startsWith('.')) rel = './' + rel;
|
|
983
|
+
return { registerFn: e.registerFn, importPath: rel };
|
|
984
|
+
})
|
|
985
|
+
.sort((a, b) => a.registerFn.localeCompare(b.registerFn));
|
|
986
|
+
globalFiles.push({ relativePath: indexPath, content: generateMcpAggregator(aggregatorEntries) });
|
|
987
|
+
|
|
988
|
+
// ── Router (global, optional) ──
|
|
989
|
+
if (config.emitRouter !== false) {
|
|
990
|
+
const routerPath = join(mcpBase, config.output?.router ?? 'mcp.router.ts');
|
|
991
|
+
globalFiles.push({ relativePath: routerPath, content: generateMcpRouter({ path: config.path }) });
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
841
995
|
// ─── Manifest IO + cleanup ─────────────────────────────────────────────────
|
|
842
996
|
|
|
843
997
|
function readManifest(manifestPath: string): IncrementalManifest {
|
|
@@ -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', () => {
|
package/tests/pipeline.test.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { parseCk, decomposeCk, validateOp, validateRefs, applyOptionsDefaults, D
|
|
|
2
2
|
import { generateContract } from '../src/codegen-contract.js';
|
|
3
3
|
import { generateOp } from '../src/codegen-operation.js';
|
|
4
4
|
import { generateSdk } from '../src/codegen-sdk.js';
|
|
5
|
+
import { generateMcpFile } from '../src/codegen-mcp.js';
|
|
5
6
|
import { SIMPLE_USER_CONTRACT, VISIBILITY_CONTRACT, INHERITANCE_CONTRACT, SIMPLE_USERS_OP, PARAMETERIZED_OP } from './helpers.js';
|
|
6
7
|
|
|
7
8
|
function compileContractSource(source: string) {
|
|
@@ -118,6 +119,64 @@ describe('OP pipeline (source -> parse -> codegen)', () => {
|
|
|
118
119
|
});
|
|
119
120
|
});
|
|
120
121
|
|
|
122
|
+
describe('MCP pipeline (source -> parse -> codegen)', () => {
|
|
123
|
+
function compileMcp(source: string, file = 'payments.ck') {
|
|
124
|
+
const diag = new DiagnosticCollector();
|
|
125
|
+
const ck = parseCk(source, file, diag);
|
|
126
|
+
const { op } = decomposeCk(ck);
|
|
127
|
+
return { output: generateMcpFile(op, { includeInternal: false }), diag };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
it('generates a tool handler from a parsed mcp block', () => {
|
|
131
|
+
const source = `\
|
|
132
|
+
operation /payments/{id}: {
|
|
133
|
+
params: { id: uuid }
|
|
134
|
+
get: {
|
|
135
|
+
mcp: {
|
|
136
|
+
title: "Get Payment"
|
|
137
|
+
description: "Fetch a payment by id."
|
|
138
|
+
hint: readOnly, idempotent, nonDestructive
|
|
139
|
+
}
|
|
140
|
+
service: PaymentsService.getById
|
|
141
|
+
response: { 200: { application/json: Payment } }
|
|
142
|
+
}
|
|
143
|
+
}`;
|
|
144
|
+
const { output, diag } = compileMcp(source);
|
|
145
|
+
expect(diag.hasErrors()).toBe(false);
|
|
146
|
+
expect(output).toContain('export class GetPaymentsByIdMcpTool implements McpToolHandler');
|
|
147
|
+
expect(output).toContain("title: 'Get Payment'");
|
|
148
|
+
expect(output).toContain('annotations: { readOnlyHint: true, destructiveHint: false, idempotentHint: true }');
|
|
149
|
+
expect(output).toContain('const GetPaymentsByIdArgs = z.object({ id: z.uuid() });');
|
|
150
|
+
expect(output).toContain('constructor(private readonly service: PaymentsService) {}');
|
|
151
|
+
expect(output).toContain('const result = await this.service.getById(id);');
|
|
152
|
+
expect(output).toContain('export function registerPaymentsMcpTools(map: McpToolHandlerMap, container: Container): void {');
|
|
153
|
+
expect(output).toContain("map.set('get_payments_by_id', container.get(GetPaymentsByIdMcpTool));");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('generates a tool from mcp: true with an inferred name', () => {
|
|
157
|
+
const source = `\
|
|
158
|
+
operation /payments: {
|
|
159
|
+
post: {
|
|
160
|
+
mcp: true
|
|
161
|
+
service: PaymentsService.create
|
|
162
|
+
request: { application/json: PaymentInput }
|
|
163
|
+
response: { 201: { application/json: Payment } }
|
|
164
|
+
}
|
|
165
|
+
}`;
|
|
166
|
+
const { output, diag } = compileMcp(source);
|
|
167
|
+
expect(diag.hasErrors()).toBe(false);
|
|
168
|
+
expect(output).toContain("name: 'post_payments'");
|
|
169
|
+
expect(output).toContain('const PostPaymentsArgs = z.object({ body: PaymentInput });');
|
|
170
|
+
expect(output).not.toContain('annotations:');
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('emits nothing tool-like for a file with no flagged ops', () => {
|
|
174
|
+
const source = `operation /payments: { get: { response: { 200: { application/json: Payment } } } }`;
|
|
175
|
+
const { output } = compileMcp(source);
|
|
176
|
+
expect(output).not.toContain('implements McpToolHandler');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
121
180
|
describe('undeclared path param warnings', () => {
|
|
122
181
|
it('warns when a route has path params but no params block', () => {
|
|
123
182
|
const source = `operation /users/{id}: { get: {} }`;
|