@contractkit/plugin-typescript 0.34.1 → 0.35.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts CHANGED
@@ -45,6 +45,16 @@ import {
45
45
  } from './codegen-sdk.js';
46
46
  import { generatePlainTypes } from './codegen-plain-types.js';
47
47
  import { DEFAULT_REVIVABLE_SCALARS } from './codegen-revive.js';
48
+ import { resolveServerFramework, SERVER_FRAMEWORK_NAMES, type ServerFrameworkName } from './server-framework.js';
49
+ export {
50
+ SERVER_FRAMEWORK_NAMES,
51
+ DEFAULT_SERVER_FRAMEWORK_NAME,
52
+ resolveServerFramework,
53
+ SERVER_FRAMEWORKS,
54
+ type ServerFramework,
55
+ type ServerFrameworkName,
56
+ } from './server-framework.js';
57
+ export { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
48
58
 
49
59
  /** Taint set for the SDK's bigint response reviver. */
50
60
  const BIGINT_SCALARS: ReadonlySet<ScalarTypeNode['name']> = new Set(['bigint']);
@@ -65,14 +75,19 @@ import {
65
75
 
66
76
  // ─── Sub-config interfaces ─────────────────────────────────────────────────
67
77
 
68
- /** Koa server output: routers, and the type or Zod schema files they import. */
78
+ /** Server output: routers, and the type or Zod schema files they import. */
69
79
  export interface ServerConfig {
70
80
  /** Directory (relative to rootDir) where server files are written. Default: rootDir. */
71
81
  baseDir?: string;
82
+ /**
83
+ * HTTP framework the generated routers target. Also selects the flavour of the optional
84
+ * `mcp.router.ts` the `mcp` sub-config emits. Supported: `'koa'`. Default `'koa'`.
85
+ */
86
+ framework?: ServerFrameworkName;
72
87
  /** When true, `output.types` emits Zod schema files (via `generateContract`). When false/omitted, emits plain TypeScript. */
73
88
  zod?: boolean;
74
89
  output?: {
75
- /** Path template for Koa router files. Supports {filename}, {dir}, {area}. */
90
+ /** Path template for router files. Supports {filename}, {dir}, {area}. */
76
91
  routes?: string;
77
92
  /** Path template for type/schema files. Supports {filename}, {dir}, {area}. */
78
93
  types?: string;
@@ -151,7 +166,7 @@ export interface McpConfig {
151
166
  */
152
167
  types?: string;
153
168
  };
154
- /** Emit the `mcp.router.ts` route boilerplate. Default true. */
169
+ /** Emit the `mcp.router.ts` route boilerplate. Its framework follows `server.framework`. Default true. */
155
170
  emitRouter?: boolean;
156
171
  /** Mount path used in the emitted router. Default `/mcp`. */
157
172
  path?: string;
@@ -207,6 +222,14 @@ export function createTypescriptPlugin(config: TypescriptPluginConfig, rootDir:
207
222
 
208
223
  /** Reject config combinations that would generate code that cannot compile or cannot run. */
209
224
  function assertValidConfig(config: TypescriptPluginConfig): void {
225
+ // Runtime check, not just a type: config arrives as JSON, so `ServerFrameworkName` constrains
226
+ // programmatic callers only. Checked before the rest so a typo'd framework is the error reported.
227
+ const framework = config.server?.framework;
228
+ if (framework !== undefined && !(SERVER_FRAMEWORK_NAMES as readonly string[]).includes(framework)) {
229
+ throw new Error(
230
+ `plugin-typescript: server.framework '${String(framework)}' is not supported — expected one of: ${SERVER_FRAMEWORK_NAMES.join(', ')}.`,
231
+ );
232
+ }
210
233
  if (config.server?.validateResponses && !config.server.zod) {
211
234
  throw new Error(
212
235
  'plugin-typescript: server.validateResponses requires server.zod: true — without it output.types emits plain TypeScript interfaces, which are types with no runtime schema value for the router to validate against.',
@@ -367,6 +390,7 @@ function collectServerOutput(
367
390
  units: IncrementalUnit[],
368
391
  ): void {
369
392
  const serverBase = resolve(rootDir, config.baseDir ?? '.');
393
+ const framework = resolveServerFramework(config.framework);
370
394
  const modelsWithInput = inputs.modelsWithInput as Set<string>;
371
395
  const modelsWithOutput = inputs.modelsWithOutput as Set<string>;
372
396
  // Not `modelsWithOutput`: that set seeds only from `format(output=...)`, because only that case
@@ -418,7 +442,7 @@ function collectServerOutput(
418
442
  currentOutPath: typeOutPath,
419
443
  modelsWithInput,
420
444
  modelsWithOutput,
421
- // These types are consumed by Koa handlers, so `binary` is a Buffer, not a Blob.
445
+ // These types are consumed by server handlers, so `binary` is a Buffer, not a Blob.
422
446
  target: 'server' as const,
423
447
  };
424
448
  const content = config.zod ? generateContract(ast, renderCtx) : generatePlainTypes(ast, renderCtx);
@@ -446,6 +470,9 @@ function collectServerOutput(
446
470
  // this router's output with no change to `root` or the config.
447
471
  modelsWithTransform: sliceModelSet(refs, new Set(), modelsWithTransform),
448
472
  validateResponses: config.validateResponses ?? false,
473
+ // Covered by `sub` already, which is the whole sub-config; explicit for the same reason
474
+ // `validateResponses` is — the inputs that change a router's text read at a glance.
475
+ framework: framework.name,
449
476
  sub: subConfigKey,
450
477
  });
451
478
  units.push({
@@ -463,6 +490,7 @@ function collectServerOutput(
463
490
  modelsWithTransform,
464
491
  includeInternal: config.includeInternal,
465
492
  validateResponses: config.validateResponses,
493
+ framework,
466
494
  }),
467
495
  },
468
496
  ],
@@ -1107,7 +1135,10 @@ function collectMcpOutput(
1107
1135
  // ── Router (global, optional) ──
1108
1136
  if (config.emitRouter !== false) {
1109
1137
  const routerPath = join(mcpBase, config.output?.router ?? 'mcp.router.ts');
1110
- globalFiles.push({ relativePath: routerPath, content: generateMcpRouter({ path: config.path }) });
1138
+ // The mount is server-side wiring, so it follows the server sub-config's framework. Koa when
1139
+ // there is no `server` sub-config at all, which is the same default the router generator has.
1140
+ const framework = resolveServerFramework(fullConfig.server?.framework);
1141
+ globalFiles.push({ relativePath: routerPath, content: generateMcpRouter({ path: config.path, framework }) });
1111
1142
  }
1112
1143
  }
1113
1144
 
@@ -0,0 +1,112 @@
1
+ import type { ServerFramework } from './server-framework.js';
2
+
3
+ /** Module the Koa flavour of ServerKit publishes its router and route middleware from. */
4
+ const KOA_RUNTIME_MODULE = '@maroonedsoftware/koa';
5
+
6
+ /**
7
+ * Symbols importable from {@link KOA_RUNTIME_MODULE}. Every one is a name the adapter itself emits,
8
+ * so none can collide with a service class or router name derived from a contract.
9
+ */
10
+ const KOA_RUNTIME_SYMBOLS = ['ServerKitRouter', 'bodyParserMiddleware', 'requirePolicy', 'requireSignature'] as const;
11
+
12
+ /**
13
+ * ServerKit on Koa: the router is a `@koa/router` instance, handlers take a single `ctx`, and a
14
+ * response is written by assigning to `ctx.status` / `ctx.type` / `ctx.body` rather than returned.
15
+ */
16
+ export const KOA_SERVER_FRAMEWORK: ServerFramework = {
17
+ name: 'koa',
18
+
19
+ imports(uses) {
20
+ const symbols = KOA_RUNTIME_SYMBOLS.filter(uses);
21
+ return symbols.length > 0 ? [`import { ${symbols.join(', ')} } from '${KOA_RUNTIME_MODULE}';`] : [];
22
+ },
23
+
24
+ routerDeclaration(routerName) {
25
+ return `export const ${routerName} = ServerKitRouter();`;
26
+ },
27
+
28
+ pathParam(identifier) {
29
+ return `:${identifier}`;
30
+ },
31
+
32
+ routeOpen(routerName, method, path, middlewares) {
33
+ const middlewareStr = middlewares.length > 0 ? `, ${middlewares.join(', ')},` : ',';
34
+ return `${routerName}.${method}('${path}'${middlewareStr} async ctx => {`;
35
+ },
36
+
37
+ routeClose() {
38
+ return ['});'];
39
+ },
40
+
41
+ middleware: {
42
+ policy(args) {
43
+ return `requirePolicy(${args})`;
44
+ },
45
+ bodyParser(tokensExpr) {
46
+ return `bodyParserMiddleware([${tokensExpr}])`;
47
+ },
48
+ signature(args) {
49
+ return `requireSignature(${args})`;
50
+ },
51
+ },
52
+
53
+ request: {
54
+ params: 'ctx.params',
55
+ query: 'ctx.query',
56
+ headers: 'ctx.headers',
57
+ // Not `ctx.request.body`: the ServerKit body parser drains the stream and writes its result
58
+ // here, and in Koa `ctx.body` is the *response* body.
59
+ parsedBody: 'ctx.parsedBody',
60
+ // Koa strips the parameters off `Content-Type` for this accessor already.
61
+ contentType: 'ctx.request.type',
62
+ },
63
+
64
+ resolveService(className) {
65
+ return `ctx.container.get(${className})`;
66
+ },
67
+
68
+ response: {
69
+ status(expr) {
70
+ return `ctx.status = ${expr};`;
71
+ },
72
+ header(name, valueExpr) {
73
+ return `ctx.set('${name}', ${valueExpr});`;
74
+ },
75
+ type(expr) {
76
+ return `ctx.type = ${expr};`;
77
+ },
78
+ send(bodyExpr) {
79
+ // A bodyless response needs no statement at all: Koa sends whatever `ctx.status` and the
80
+ // headers say once the handler resolves.
81
+ return bodyExpr === undefined ? [] : [`ctx.body = ${bodyExpr};`];
82
+ },
83
+ caseEnd() {
84
+ return ['break;'];
85
+ },
86
+ },
87
+
88
+ mcpRouter({ path }) {
89
+ return `import { ServerKitRouter, bodyParserMiddleware, requireSignature } from '${KOA_RUNTIME_MODULE}';
90
+ import { McpDispatcher, createMcpRequestContext, MCP_AUTH_POLICY } from '@maroonedsoftware/mcp';
91
+
92
+ /** Mount the MCP endpoint onto a ServerKit router. Bind \`registerMcpTools\` to the \`McpToolHandlerMap\` token. */
93
+ export function mountMcp(router: ReturnType<typeof ServerKitRouter>): void {
94
+ router.post('${path}', bodyParserMiddleware(['json']), requireSignature('mcp', { policy: MCP_AUTH_POLICY }), async (ctx) => {
95
+ const dispatcher = ctx.container.get(McpDispatcher);
96
+ const context = createMcpRequestContext({ requestId: ctx.requestId, logger: ctx.logger });
97
+ if (dispatcher.sessionMode === 'stateful') {
98
+ ctx.respond = false;
99
+ await dispatcher.dispatchStateful(
100
+ { req: ctx.req, res: ctx.res, body: ctx.parsedBody, sessionId: ctx.get('mcp-session-id') },
101
+ context,
102
+ );
103
+ } else {
104
+ const response = await dispatcher.dispatch(JSON.parse(String(ctx.rawBody)), context);
105
+ if (response) ctx.body = response;
106
+ else ctx.status = 202; // a notification — nothing to return
107
+ }
108
+ });
109
+ }
110
+ `;
111
+ },
112
+ };
@@ -0,0 +1,119 @@
1
+ import { KOA_SERVER_FRAMEWORK } from './server-framework-koa.js';
2
+
3
+ /**
4
+ * HTTP frameworks the server sub-generator can target. Adding a name here without adding an adapter
5
+ * to {@link SERVER_FRAMEWORKS} fails to compile, which is the point of keeping the two in step.
6
+ */
7
+ export const SERVER_FRAMEWORK_NAMES = ['koa'] as const;
8
+
9
+ /** One of {@link SERVER_FRAMEWORK_NAMES}. */
10
+ export type ServerFrameworkName = (typeof SERVER_FRAMEWORK_NAMES)[number];
11
+
12
+ /** The framework assumed when a config names none. */
13
+ export const DEFAULT_SERVER_FRAMEWORK_NAME: ServerFrameworkName = 'koa';
14
+
15
+ /**
16
+ * Every framework-specific string the router and MCP router generators emit.
17
+ *
18
+ * Granularity is one statement (or one fragment) per method, so the shared codegen keeps ownership
19
+ * of control flow — which branches exist, what order they run in, and which values reach them — and
20
+ * an adapter only decides how a given step is spelled. Anything an adapter cannot express as a
21
+ * statement, such as ending a response, is returned as a list of lines so it can also be empty.
22
+ */
23
+ export interface ServerFramework {
24
+ readonly name: ServerFrameworkName;
25
+
26
+ /**
27
+ * Import lines for the framework runtime, already filtered down to what the generated body uses.
28
+ *
29
+ * The adapter applies `uses` itself rather than declaring a symbol list, because a framework may
30
+ * need more than one import line, and because only names the adapter chooses ever go through the
31
+ * word-boundary probe — a handler-local identifier can never be mistaken for an import.
32
+ */
33
+ imports(uses: (symbol: string) => boolean): string[];
34
+
35
+ /** The module-level router value every handler attaches to. */
36
+ routerDeclaration(routerName: string): string;
37
+
38
+ /** Placeholder syntax for one path parameter, given a name already mapped to a valid identifier. */
39
+ pathParam(identifier: string): string;
40
+
41
+ /** Opening line of a handler, including its middleware and the handler function's parameters. */
42
+ routeOpen(routerName: string, method: string, path: string, middlewares: readonly string[]): string;
43
+
44
+ /** Lines that close a handler opened by {@link routeOpen}. */
45
+ routeClose(): string[];
46
+
47
+ /** Route middleware factory calls, rendered as expressions for {@link routeOpen}. */
48
+ readonly middleware: {
49
+ policy(args: string): string;
50
+ bodyParser(tokensExpr: string): string;
51
+ signature(args: string): string;
52
+ };
53
+
54
+ /** Expressions a handler reads the request through. */
55
+ readonly request: {
56
+ params: string;
57
+ query: string;
58
+ headers: string;
59
+ /** The body already parsed by the body-parser middleware. */
60
+ parsedBody: string;
61
+ /**
62
+ * The request's media type with any parameters stripped. It is matched against declared MIME
63
+ * literals, so an adapter whose framework exposes only the raw header must normalise it here
64
+ * — a `; charset=utf-8` left on the end matches nothing.
65
+ */
66
+ contentType: string;
67
+ };
68
+
69
+ /** Expression resolving a service class out of the request-scoped DI container. */
70
+ resolveService(className: string): string;
71
+
72
+ /** Statements a handler writes the response with. */
73
+ readonly response: {
74
+ status(expr: string): string;
75
+ /**
76
+ * One statement setting a response header. It is emitted bare or behind an `if` guard for an
77
+ * optional header, so it must stay a single statement.
78
+ */
79
+ header(name: string, valueExpr: string): string;
80
+ type(expr: string): string;
81
+ /**
82
+ * The terminal write for a response, or for one without a body when `bodyExpr` is undefined.
83
+ * A framework that ends a response by returning needs a statement in both cases; Koa, which
84
+ * ends it by assignment, emits nothing for a bodyless one.
85
+ */
86
+ send(bodyExpr: string | undefined): string[];
87
+ /** What closes one `case` of the multi-status switch, after that status has been written. */
88
+ caseEnd(): string[];
89
+ };
90
+
91
+ /** The whole `mcp.router.ts` file, which is boilerplate rather than a per-operation render. */
92
+ mcpRouter(options: { path: string }): string;
93
+ }
94
+
95
+ /**
96
+ * Every supported framework, keyed by name. The annotation is what ties this to
97
+ * {@link SERVER_FRAMEWORK_NAMES}: adding a name without an adapter is a compile error.
98
+ */
99
+ export const SERVER_FRAMEWORKS: Readonly<Record<ServerFrameworkName, ServerFramework>> = {
100
+ koa: KOA_SERVER_FRAMEWORK,
101
+ };
102
+
103
+ /**
104
+ * Resolve a configured framework name to its adapter.
105
+ *
106
+ * @param name The `server.framework` value, or undefined for {@link DEFAULT_SERVER_FRAMEWORK_NAME}.
107
+ * @throws When `name` is not a supported framework. Config arrives as JSON, so this is a runtime
108
+ * check and not something the `ServerFrameworkName` type can enforce on its own.
109
+ */
110
+ export function resolveServerFramework(name: string | undefined): ServerFramework {
111
+ const resolved = name ?? DEFAULT_SERVER_FRAMEWORK_NAME;
112
+ const framework = (SERVER_FRAMEWORKS as Record<string, ServerFramework | undefined>)[resolved];
113
+ if (!framework) {
114
+ throw new Error(
115
+ `plugin-typescript: server.framework '${resolved}' is not supported — expected one of: ${SERVER_FRAMEWORK_NAMES.join(', ')}.`,
116
+ );
117
+ }
118
+ return framework;
119
+ }
@@ -286,6 +286,18 @@ describe('generateMcpRouter', () => {
286
286
  expect(out).not.toContain('ctx.request.body');
287
287
  });
288
288
 
289
+ it('delegates the whole file to the framework adapter', () => {
290
+ const stub = { mcpRouter: ({ path }: { path: string }) => `// stub mount at ${path}` };
291
+ // Only `mcpRouter` is reachable from here, so the rest of the adapter is left off the stub.
292
+ const out = generateMcpRouter({ framework: stub as never });
293
+ expect(out).toBe('// stub mount at /mcp');
294
+ });
295
+
296
+ it('passes a configured path through to the adapter', () => {
297
+ const stub = { mcpRouter: ({ path }: { path: string }) => `// stub mount at ${path}` };
298
+ expect(generateMcpRouter({ path: '/tools', framework: stub as never })).toBe('// stub mount at /tools');
299
+ });
300
+
289
301
  it('defaults the mount path to /mcp', () => {
290
302
  expect(generateMcpRouter()).toContain("router.post('/mcp'");
291
303
  });
@@ -0,0 +1,140 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateOp } from '../src/codegen-operation.js';
3
+ import type { ServerFramework } from '../src/server-framework.js';
4
+ import { scalarType, refType, opParam, opRequest, opMultiRequest, opResponse, opResponseMulti, opOperation, opRoute, opRoot } from './helpers.js';
5
+
6
+ /**
7
+ * A framework whose every string is unmistakable. Rendering a router through it and finding no Koa
8
+ * left in the output is what proves the seam is complete: a `ctx.` the generator still emits inline
9
+ * would survive this substitution, and a `toContain` test against the Koa output never notices,
10
+ * because the default adapter puts the very same string there.
11
+ */
12
+ const STUB: ServerFramework = {
13
+ // The registry's key type admits only shipped frameworks; the adapter under test is a fake.
14
+ name: 'koa',
15
+ imports: uses => (uses('StubRouter') ? ["import { StubRouter } from '@stub/http';"] : []),
16
+ routerDeclaration: routerName => `export const ${routerName} = StubRouter();`,
17
+ pathParam: identifier => `<${identifier}>`,
18
+ routeOpen: (routerName, method, path, middlewares) => `${routerName}.route('${method}', '${path}', [${middlewares.join(', ')}], async (rq, rs) => {`,
19
+ routeClose: () => ['}, END);'],
20
+ middleware: {
21
+ policy: args => `stubPolicy(${args})`,
22
+ bodyParser: tokens => `stubBody(${tokens})`,
23
+ signature: args => `stubSignature(${args})`,
24
+ },
25
+ request: {
26
+ params: 'rq.pathParams',
27
+ query: 'rq.searchParams',
28
+ headers: 'rq.headerBag',
29
+ parsedBody: 'rq.payload',
30
+ contentType: 'rq.mediaType',
31
+ },
32
+ resolveService: className => `rq.services.resolve(${className})`,
33
+ response: {
34
+ status: expr => `rs.setStatus(${expr});`,
35
+ header: (name, valueExpr) => `rs.putHeader('${name}', ${valueExpr});`,
36
+ type: expr => `rs.setMedia(${expr});`,
37
+ send: bodyExpr => (bodyExpr === undefined ? ['return rs.finish();'] : [`return rs.deliver(${bodyExpr});`]),
38
+ caseEnd: () => [],
39
+ },
40
+ mcpRouter: ({ path }) => `// stub mcp at ${path}\n`,
41
+ };
42
+
43
+ /** One op root touching every branch of the generator that can emit a framework string. */
44
+ function everyBranchRoot() {
45
+ return opRoot([
46
+ opRoute(
47
+ '/payments/{paymentId}',
48
+ [
49
+ // Path params + query + headers + a single JSON body, with policy and signature middleware.
50
+ opOperation('post', {
51
+ request: opRequest('Payment'),
52
+ query: [opParam('limit', scalarType('int'))],
53
+ headers: [opParam('x-tenant', scalarType('string'))],
54
+ signature: 'stripe',
55
+ responses: [opResponse(201, 'Payment')],
56
+ }),
57
+ // Several request MIMEs with different shapes — the content-type switch.
58
+ opOperation('put', {
59
+ request: opMultiRequest([
60
+ ['application/json', 'Payment'],
61
+ ['multipart/form-data', 'Receipt'],
62
+ ]),
63
+ responses: [opResponse(200, 'Payment')],
64
+ }),
65
+ // No emitted body at all — the bodyless 204 path.
66
+ opOperation('delete', { responses: [] }),
67
+ // Several emitted statuses, one carrying response headers — the status switch.
68
+ opOperation('patch', {
69
+ responses: [
70
+ opResponseMulti(200, [{ contentType: 'application/json', bodyType: 'Payment' }], {
71
+ headers: [{ name: 'etag', optional: false, type: scalarType('string') }],
72
+ }),
73
+ opResponseMulti(202, [{ contentType: 'application/json', bodyType: refType('Payment') }], {
74
+ headers: [{ name: 'retry-after', optional: true, type: scalarType('string') }],
75
+ }),
76
+ ],
77
+ }),
78
+ ],
79
+ [opParam('paymentId', scalarType('uuid'))],
80
+ ),
81
+ ]);
82
+ }
83
+
84
+ describe('generateOp — framework seam', () => {
85
+ const output = generateOp(everyBranchRoot(), { framework: STUB });
86
+
87
+ it('leaves no Koa string anywhere in the output', () => {
88
+ expect(output).not.toMatch(/\bctx\b/);
89
+ expect(output).not.toContain('ServerKitRouter');
90
+ expect(output).not.toContain('@maroonedsoftware/koa');
91
+ expect(output).not.toContain('requirePolicy');
92
+ expect(output).not.toContain('bodyParserMiddleware');
93
+ expect(output).not.toContain('requireSignature');
94
+ });
95
+
96
+ it('renders the router shell through the adapter', () => {
97
+ expect(output).toContain('export const UsersRouter = StubRouter();');
98
+ expect(output).toContain("import { StubRouter } from '@stub/http';");
99
+ expect(output).toContain('}, END);');
100
+ });
101
+
102
+ it('renders the route line, its path params and its middleware through the adapter', () => {
103
+ expect(output).toContain("UsersRouter.route('post', '/payments/<paymentId>', [stubPolicy(), stubBody('json'), stubSignature('stripe')], async (rq, rs) => {");
104
+ });
105
+
106
+ it('reads params, query, headers and the body through the adapter', () => {
107
+ expect(output).toContain('rq.pathParams');
108
+ expect(output).toContain('rq.searchParams');
109
+ expect(output).toContain('rq.headerBag');
110
+ expect(output).toContain('rq.payload');
111
+ expect(output).toContain('switch (rq.mediaType) {');
112
+ });
113
+
114
+ it('resolves services through the adapter', () => {
115
+ expect(output).toContain('rq.services.resolve(UsersService)');
116
+ });
117
+
118
+ it('writes status, headers, content type and body through the adapter', () => {
119
+ expect(output).toContain('rs.setStatus(201);');
120
+ expect(output).toContain("rs.setMedia('application/json');");
121
+ expect(output).toContain('return rs.deliver(result);');
122
+ expect(output).toContain('rs.putHeader(\'etag\', String(result.headers["etag"]));');
123
+ expect(output).toContain('if (result.headers["retryAfter"] !== undefined) rs.putHeader(\'retry-after\', String(result.headers["retryAfter"]));');
124
+ });
125
+
126
+ it('gives a bodyless response the adapter\'s terminal statement', () => {
127
+ // Koa needs none, so the generator only emits one because the adapter asked for it.
128
+ expect(output).toContain('rs.setStatus(204);');
129
+ expect(output).toContain('return rs.finish();');
130
+ });
131
+
132
+ it('omits the status-case terminator when the adapter has none', () => {
133
+ expect(output).toContain('rs.setStatus(result.status);');
134
+ // Scoped to the status switch: the multi-MIME request dispatch is the generator's own control
135
+ // flow and keeps its `break;` whatever the framework is.
136
+ const statusSwitch = output.slice(output.indexOf('switch (result.status) {'));
137
+ expect(statusSwitch).toContain('case 202:');
138
+ expect(statusSwitch).not.toContain('break;');
139
+ });
140
+ });
@@ -567,6 +567,35 @@ describe('generateOperation', () => {
567
567
  expect(output).toContain('z.object({');
568
568
  });
569
569
 
570
+ it('renders an inline array header through renderInputType, without the query coercion', () => {
571
+ // The comma-splitting preprocess belongs to query strings only. Headers and params share
572
+ // the same inline-param renderer, so the block has to be told which one it is rendering.
573
+ const root = opRoot([
574
+ opRoute('/users', [
575
+ opOperation('get', {
576
+ headers: [opParam('x-tags', arrayType(scalarType('string')))],
577
+ }),
578
+ ]),
579
+ ]);
580
+ const output = generateOp(root);
581
+ expect(output).toContain('ctx.headers');
582
+ expect(output).not.toContain('z.preprocess');
583
+ expect(output).not.toContain("v.split(',')");
584
+ });
585
+
586
+ it('declares headers as a whole object rather than destructuring it', () => {
587
+ const root = opRoot([
588
+ opRoute('/users', [
589
+ opOperation('get', {
590
+ headers: [opParam('authorization', scalarType('string'))],
591
+ }),
592
+ ]),
593
+ ]);
594
+ const output = generateOp(root);
595
+ expect(output).toContain('const headers = await parseAndValidate(');
596
+ expect(output).not.toContain('const { authorization }');
597
+ });
598
+
570
599
  it('generates parseAndValidate import when operation has headers', () => {
571
600
  const root = opRoot([
572
601
  opRoute('/users', [
@@ -37,6 +37,13 @@ function inputs(opRoots = [opRoot([opRoute('/users', [opOperation('get')])], '/p
37
37
  };
38
38
  }
39
39
 
40
+ /** Inputs carrying an MCP-exposed operation, which is what makes the mcp sub-generator emit a router. */
41
+ function mcpInputs() {
42
+ return inputs([
43
+ opRoot([opRoute('/users', [opOperation('get', { mcp: true, responses: [opResponse(200, 'User', 'application/json')] })])], '/project/contracts/users.ck'),
44
+ ]);
45
+ }
46
+
40
47
  // ─── Tests ─────────────────────────────────────────────────────────────────
41
48
 
42
49
  describe('createTypescriptPlugin (server)', () => {
@@ -224,6 +231,55 @@ describe('createTypescriptPlugin (server)', () => {
224
231
  });
225
232
  });
226
233
 
234
+ describe('framework', () => {
235
+ it('emits a Koa router when no framework is configured', async () => {
236
+ const plugin = createTypescriptPlugin({ server: {} }, '/project');
237
+ const ctx = makeCtx('/project');
238
+ await plugin.generateTargets!(inputs(), ctx);
239
+ const [content] = [...ctx.emitted.values()];
240
+ expect(content).toContain("from '@maroonedsoftware/koa'");
241
+ });
242
+
243
+ it('emits byte-identical output when koa is named explicitly', async () => {
244
+ const runWith = async (server: Record<string, unknown>) => {
245
+ const ctx = makeCtx('/project');
246
+ await createTypescriptPlugin({ server }, '/project').generateTargets!(inputs(), ctx);
247
+ return [...ctx.emitted.values()].join('\n');
248
+ };
249
+ expect(await runWith({ framework: 'koa' })).toEqual(await runWith({}));
250
+ });
251
+
252
+ it('rejects a framework that has no adapter', async () => {
253
+ const plugin = createTypescriptPlugin({ server: { framework: 'express' as never } }, '/project');
254
+ await expect(plugin.generateTargets!(inputs(), makeCtx('/project'))).rejects.toThrow(
255
+ /server\.framework 'express' is not supported — expected one of: koa/,
256
+ );
257
+ });
258
+
259
+ it('rejects the unknown framework before any other config complaint', async () => {
260
+ // Both rules are broken here; the framework is the one reported, since a router for a
261
+ // framework that does not exist cannot be generated whatever the other settings say.
262
+ const plugin = createTypescriptPlugin({ server: { framework: 'express' as never, validateResponses: true } }, '/project');
263
+ await expect(plugin.generateTargets!(inputs(), makeCtx('/project'))).rejects.toThrow(/server\.framework/);
264
+ });
265
+
266
+ it('renders mcp.router.ts for the configured framework', async () => {
267
+ const plugin = createTypescriptPlugin({ server: { framework: 'koa' }, mcp: {} }, '/project');
268
+ const ctx = makeCtx('/project');
269
+ await plugin.generateTargets!(mcpInputs(), ctx);
270
+ const router = [...ctx.emitted.entries()].find(([p]) => p.endsWith('mcp.router.ts'))?.[1];
271
+ expect(router).toContain("from '@maroonedsoftware/koa'");
272
+ });
273
+
274
+ it('still emits a Koa mcp.router.ts when there is no server sub-config', async () => {
275
+ const plugin = createTypescriptPlugin({ mcp: {} }, '/project');
276
+ const ctx = makeCtx('/project');
277
+ await plugin.generateTargets!(mcpInputs(), ctx);
278
+ const router = [...ctx.emitted.entries()].find(([p]) => p.endsWith('mcp.router.ts'))?.[1];
279
+ expect(router).toContain("from '@maroonedsoftware/koa'");
280
+ });
281
+ });
282
+
227
283
  describe('validateResponses', () => {
228
284
  const userRoot = () =>
229
285
  opRoot(