@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/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', () => {
@@ -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: {} }`;