@forinda/kickjs-mcp 2.3.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Felix Orinda
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,86 @@
1
+ # @forinda/kickjs-mcp
2
+
3
+ [Model Context Protocol](https://modelcontextprotocol.io) server adapter for KickJS. Expose `@Controller` endpoints as callable MCP tools for Claude Code, Cursor, Zed, and other MCP-aware clients — with zero duplicated schemas.
4
+
5
+ ## Status
6
+
7
+ **v0 — skeleton.** The decorator and adapter surface exist and compile against the framework. The tool-discovery scan and MCP SDK wiring are still TODOs inside the adapter's lifecycle hooks. This package is part of Workstream 1 of the v3 AI plan and will reach v1 when:
8
+
9
+ - [ ] Tool discovery scans every `@Controller` registered in the DI container
10
+ - [ ] Zod body schemas are converted to JSON Schema via the shared Swagger converter
11
+ - [ ] `stdio`, `sse`, and `http` transports are wired to `@modelcontextprotocol/sdk`
12
+ - [ ] The `kick mcp` CLI command starts a standalone MCP server
13
+ - [ ] Integration test: start a KickJS app, connect an MCP client, call a tool
14
+ - [ ] Example app in `examples/mcp-server-api/`
15
+
16
+ ## Install
17
+
18
+ ```bash
19
+ pnpm add @forinda/kickjs-mcp @modelcontextprotocol/sdk
20
+ ```
21
+
22
+ ## Usage (planned)
23
+
24
+ ```ts
25
+ import { bootstrap } from '@forinda/kickjs'
26
+ import { McpAdapter } from '@forinda/kickjs-mcp'
27
+ import { modules } from './modules'
28
+
29
+ export const app = await bootstrap({
30
+ modules,
31
+ adapters: [
32
+ new McpAdapter({
33
+ name: 'task-api',
34
+ version: '1.0.0',
35
+ description: 'Task management MCP server',
36
+ mode: 'explicit', // only @McpTool-decorated methods
37
+ transport: 'sse',
38
+ }),
39
+ ],
40
+ })
41
+ ```
42
+
43
+ Mark controller methods with `@McpTool` to expose them:
44
+
45
+ ```ts
46
+ import { Controller, Post, type Ctx } from '@forinda/kickjs'
47
+ import { McpTool } from '@forinda/kickjs-mcp'
48
+ import { createTaskSchema } from './dtos/create-task.dto'
49
+
50
+ @Controller('/tasks')
51
+ export class TaskController {
52
+ @Post('/', { body: createTaskSchema, name: 'CreateTask' })
53
+ @McpTool({
54
+ description: 'Create a new task with title, priority, and optional assignee',
55
+ examples: [
56
+ { args: { title: 'Ship v3', priority: 'high' } },
57
+ ],
58
+ })
59
+ create(ctx: Ctx<KickRoutes.TaskController['create']>) {
60
+ return this.createTaskUseCase.execute(ctx.body)
61
+ }
62
+ }
63
+ ```
64
+
65
+ The input schema of each tool is derived automatically from the route's Zod `body` schema. Add a field, and both OpenAPI (via the Swagger adapter) and MCP pick it up on the next restart — no duplicated type declarations.
66
+
67
+ ## Exposure modes
68
+
69
+ | Mode | Behavior |
70
+ |------|----------|
71
+ | `explicit` (default) | Only methods decorated with `@McpTool` are exposed. |
72
+ | `auto` | Every route is exposed, subject to `include` / `exclude` filters. |
73
+
74
+ Use `explicit` in production unless you've carefully reviewed every route. `auto` is convenient during development but can leak admin or internal endpoints if you forget to filter them out.
75
+
76
+ ## Transports
77
+
78
+ | Transport | Use case |
79
+ |-----------|----------|
80
+ | `stdio` | CLI MCP clients (Claude Code, Cursor). Run via `kick mcp` in a separate process. |
81
+ | `sse` | Mounted on the existing Express app. Default for long-running servers. |
82
+ | `http` | Simpler than SSE; gives up live notifications. |
83
+
84
+ ## License
85
+
86
+ MIT
@@ -0,0 +1,461 @@
1
+
2
+ import { AdapterContext, AppAdapter, Constructor } from "@forinda/kickjs";
3
+ import { ZodTypeAny } from "zod";
4
+
5
+ //#region src/types.d.ts
6
+ /**
7
+ * Transport modes supported by the MCP adapter.
8
+ *
9
+ * - `stdio` — standard MCP transport for CLI clients (Claude Code, Cursor).
10
+ * The MCP server owns stdin/stdout. Cannot be combined with a normal
11
+ * Express dev server in the same process without care.
12
+ * - `sse` — Server-Sent Events over HTTP. Good fit when KickJS already
13
+ * exposes an HTTP server — the MCP endpoints mount on the same app.
14
+ * - `http` — plain HTTP POST/GET streaming. Simpler than SSE for some
15
+ * clients but gives up live notifications.
16
+ */
17
+ type McpTransport = 'stdio' | 'sse' | 'http';
18
+ /**
19
+ * How the adapter decides which endpoints become MCP tools.
20
+ *
21
+ * - `explicit` (default) — only methods decorated with `@McpTool` are
22
+ * exposed. Safest default; prevents accidental exposure of internal
23
+ * endpoints or admin routes.
24
+ * - `auto` — every route discovered at startup becomes a tool, subject
25
+ * to the `include` / `exclude` filters. Use with care in production.
26
+ */
27
+ type McpExposureMode = 'explicit' | 'auto';
28
+ /**
29
+ * Authentication configuration for the MCP transport.
30
+ *
31
+ * For `stdio`, auth is usually not needed (client and server share a
32
+ * process). For `sse` and `http`, set this so the adapter refuses
33
+ * unauthenticated tool calls.
34
+ */
35
+ interface McpAuthOptions {
36
+ /** Strategy to use. `bearer` reads `Authorization: Bearer <token>`. */
37
+ type: 'bearer' | 'custom';
38
+ /** Called on every tool invocation. Return true (or truthy data) to allow. */
39
+ validate: (token: string) => boolean | Promise<boolean>;
40
+ }
41
+ /**
42
+ * Options for the `McpAdapter` constructor.
43
+ *
44
+ * @example
45
+ * ```ts
46
+ * new McpAdapter({
47
+ * name: 'task-api',
48
+ * version: '1.0.0',
49
+ * description: 'Task management MCP server',
50
+ * mode: 'explicit',
51
+ * transport: 'sse',
52
+ * })
53
+ * ```
54
+ */
55
+ interface McpAdapterOptions {
56
+ /** MCP server name advertised to clients. Usually matches package.json name. */
57
+ name: string;
58
+ /** Server version advertised to clients. Defaults to '0.0.0' if omitted. */
59
+ version?: string;
60
+ /** Human-readable description shown in MCP client UIs. */
61
+ description?: string;
62
+ /** Exposure mode. Defaults to `'explicit'`. */
63
+ mode?: McpExposureMode;
64
+ /** Transport mode. Defaults to `'sse'`. */
65
+ transport?: McpTransport;
66
+ /** HTTP methods to include when `mode === 'auto'`. */
67
+ include?: Array<'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'>;
68
+ /** Glob-style path prefixes to exclude when `mode === 'auto'`. */
69
+ exclude?: string[];
70
+ /** Auth config for `sse` and `http` transports. */
71
+ auth?: McpAuthOptions;
72
+ /** Base path for the MCP endpoint (SSE/HTTP only). Defaults to `/_mcp`. */
73
+ basePath?: string;
74
+ }
75
+ /**
76
+ * Example input/output pair shown alongside a tool description.
77
+ *
78
+ * Models can use examples to learn the expected shape of arguments and
79
+ * what a successful call returns. Keep examples small and representative.
80
+ */
81
+ interface McpToolExample {
82
+ /** Natural-language description of what this example does. */
83
+ description?: string;
84
+ /** Arguments to pass to the tool. Must match the Zod input schema. */
85
+ args: Record<string, unknown>;
86
+ /** Expected result shape. Used in docs only — not validated. */
87
+ result?: unknown;
88
+ }
89
+ /**
90
+ * Options for the `@McpTool` decorator.
91
+ *
92
+ * @example
93
+ * ```ts
94
+ * @Post('/', { body: createTaskSchema, name: 'CreateTask' })
95
+ * @McpTool({
96
+ * description: 'Create a new task',
97
+ * examples: [{ args: { title: 'Ship v3', priority: 'high' } }],
98
+ * })
99
+ * create(ctx: Ctx<KickRoutes.TaskController['create']>) {}
100
+ * ```
101
+ */
102
+ interface McpToolOptions {
103
+ /**
104
+ * Override the tool name. Defaults to `<ControllerName>.<methodName>`.
105
+ * Tool names must be unique across the entire MCP server.
106
+ */
107
+ name?: string;
108
+ /**
109
+ * Human-readable description of what the tool does. Shown to the LLM
110
+ * when it decides whether to call this tool. Be specific: "Create a
111
+ * task" is less useful than "Create a new task with the given title,
112
+ * priority, and optional assignee".
113
+ */
114
+ description: string;
115
+ /**
116
+ * Optional input schema override. If omitted, the adapter derives
117
+ * the input schema from the route's `body` Zod schema (if any).
118
+ */
119
+ inputSchema?: ZodTypeAny;
120
+ /**
121
+ * Optional output schema for documentation. Not validated at runtime.
122
+ */
123
+ outputSchema?: ZodTypeAny;
124
+ /** Optional usage examples shown in the tool description. */
125
+ examples?: McpToolExample[];
126
+ /**
127
+ * When set to `true`, exclude this tool from any `auto` exposure mode
128
+ * filter. Useful to mark admin-only routes inside otherwise-exposed
129
+ * controllers.
130
+ */
131
+ hidden?: boolean;
132
+ }
133
+ /**
134
+ * Resolved tool definition after scanning decorators at startup.
135
+ *
136
+ * This is the shape the adapter hands to the MCP SDK when registering
137
+ * tools. Users don't construct this directly — it's derived from
138
+ * `@McpTool` metadata plus route metadata from `@Controller`.
139
+ *
140
+ * Both the raw Zod schema (`zodInputSchema`) and the converted JSON
141
+ * Schema (`inputSchema`) are kept on the definition. The MCP SDK
142
+ * accepts the Zod form directly, while the JSON Schema is exposed via
143
+ * `getTools()` for inspection, the `kick mcp --list` command, and
144
+ * documentation surfaces.
145
+ */
146
+ interface McpToolDefinition {
147
+ /** Resolved tool name (either from options.name or derived). */
148
+ name: string;
149
+ /** Human-readable description. */
150
+ description: string;
151
+ /** JSON Schema for tool inputs, derived from the Zod body schema. */
152
+ inputSchema: Record<string, unknown>;
153
+ /**
154
+ * Original Zod schema for the tool input, when one was attached to
155
+ * the route via `body` (or via `@McpTool({ inputSchema })`). The MCP
156
+ * SDK accepts this form directly via `registerTool`. May be undefined
157
+ * for routes without a body schema.
158
+ */
159
+ zodInputSchema?: unknown;
160
+ /** Optional JSON Schema for tool outputs. */
161
+ outputSchema?: Record<string, unknown>;
162
+ /** HTTP method of the underlying route. */
163
+ httpMethod: string;
164
+ /** Full mount path of the underlying route (after apiPrefix + version). */
165
+ mountPath: string;
166
+ /** Examples for documentation. */
167
+ examples?: McpToolExample[];
168
+ }
169
+ //#endregion
170
+ //#region src/mcp.adapter.d.ts
171
+ /**
172
+ * Expose a KickJS application as a Model Context Protocol (MCP) server.
173
+ *
174
+ * The adapter implements `onRouteMount` to collect every registered
175
+ * controller alongside its mount path. During `beforeStart` (after all
176
+ * modules have finished mounting), it walks the collected controllers,
177
+ * reads route metadata via `getClassMeta(METADATA.ROUTES, ...)`, and
178
+ * builds a `McpToolDefinition[]` that the MCP SDK will register as
179
+ * callable tools.
180
+ *
181
+ * The input schema of each tool is the JSON Schema equivalent of the
182
+ * route's Zod `body` schema, converted via the package's own
183
+ * `zod-to-json-schema` helper. Tools with no body schema get an empty
184
+ * object schema so the model can still call them with no arguments.
185
+ *
186
+ * @example
187
+ * ```ts
188
+ * import { bootstrap } from '@forinda/kickjs'
189
+ * import { McpAdapter } from '@forinda/kickjs-mcp'
190
+ * import { modules } from './modules'
191
+ *
192
+ * export const app = await bootstrap({
193
+ * modules,
194
+ * adapters: [
195
+ * new McpAdapter({
196
+ * name: 'task-api',
197
+ * version: '1.0.0',
198
+ * description: 'Task management MCP server',
199
+ * mode: 'explicit',
200
+ * transport: 'sse',
201
+ * }),
202
+ * ],
203
+ * })
204
+ * ```
205
+ *
206
+ * @remarks
207
+ * Tool discovery is complete. The remaining work for v1 is wiring the
208
+ * generated tool definitions to `@modelcontextprotocol/sdk` in
209
+ * `afterStart` — see the TODO markers in that hook. Until then,
210
+ * `getTools()` returns the discovered definitions so tests can assert
211
+ * the scan produced the expected shape.
212
+ */
213
+ declare class McpAdapter implements AppAdapter {
214
+ readonly name = "McpAdapter";
215
+ private readonly options;
216
+ /** Controllers collected during the mount phase, in insertion order. */
217
+ private readonly mountedControllers;
218
+ /** Discovered tool definitions, built during `beforeStart`. */
219
+ private readonly tools;
220
+ /** Active MCP server instance, created in `afterStart`. */
221
+ private mcpServer;
222
+ /**
223
+ * Active MCP transport, created in `afterStart`. Can be either a
224
+ * `StreamableHTTPServerTransport` (the default for HTTP-based MCP)
225
+ * or a `StdioServerTransport` (when running via the `kick mcp` CLI
226
+ * or with `KICK_MCP_STDIO=1`).
227
+ */
228
+ private transport;
229
+ /**
230
+ * Base URL of the running KickJS HTTP server, captured in `afterStart`
231
+ * once the server is listening. Tool dispatch makes internal HTTP
232
+ * requests against this base URL so calls flow through the normal
233
+ * Express pipeline (middleware, validation, auth, logging, error
234
+ * handling) rather than bypassing it.
235
+ *
236
+ * Format: `http://127.0.0.1:<port>`. Set to `null` until afterStart
237
+ * runs and reset to `null` on shutdown.
238
+ */
239
+ private serverBaseUrl;
240
+ constructor(options: McpAdapterOptions);
241
+ /**
242
+ * Called by the framework each time a module mounts a controller.
243
+ *
244
+ * We don't inspect routes here — we just record the pair and process
245
+ * everything in `beforeStart` once mounting is fully complete. This
246
+ * keeps the scan logic in one place and makes it easier to unit test.
247
+ */
248
+ onRouteMount(controller: Constructor, mountPath: string): void;
249
+ /**
250
+ * Walk collected controllers, read route metadata, and materialize
251
+ * `McpToolDefinition[]`.
252
+ *
253
+ * Runs after every module has mounted but before the HTTP server
254
+ * starts listening, so the MCP server can be initialized in
255
+ * `afterStart` with a complete tool list.
256
+ */
257
+ beforeStart(_ctx: AdapterContext): void;
258
+ /**
259
+ * Start the MCP server on the configured transport.
260
+ *
261
+ * - `http` (recommended): mounts a `StreamableHTTPServerTransport` on
262
+ * the existing Express app at `${basePath}/messages`. This is the
263
+ * modern, spec-compliant way to expose MCP over HTTP.
264
+ * - `sse` (deprecated): currently aliases to `http` and emits a warning.
265
+ * The MCP SSE transport class is deprecated upstream in favor of
266
+ * StreamableHTTP, which already supports SSE-style streaming under
267
+ * the hood.
268
+ * - `stdio`: skipped here. The standalone `kick mcp` CLI command
269
+ * instantiates the adapter directly and connects it to a stdio
270
+ * transport so dev logs don't interfere.
271
+ */
272
+ afterStart(ctx: AdapterContext): Promise<void>;
273
+ /**
274
+ * Decide which transport to actually start.
275
+ *
276
+ * Precedence: an explicit `KICK_MCP_STDIO=1` environment variable
277
+ * always wins, because that's how the `kick mcp` CLI command tells
278
+ * the running process to switch to stdio mode without requiring the
279
+ * user to edit their bootstrap. Otherwise the constructor option is
280
+ * honored as-is.
281
+ */
282
+ private resolveTransportMode;
283
+ /**
284
+ * Start the MCP server bound to process stdio.
285
+ *
286
+ * Used by the `kick mcp` CLI: the parent process pipes its stdin/
287
+ * stdout to this adapter so MCP clients (Claude Code, Cursor) can
288
+ * speak the protocol over the wire. Logs MUST go to stderr in this
289
+ * mode — anything written to stdout corrupts the JSON-RPC stream.
290
+ *
291
+ * The HTTP server is still running (the framework called start()
292
+ * before afterStart), but we don't mount the MCP routes on it. Tool
293
+ * dispatch routes through fetch against `serverBaseUrl` exactly the
294
+ * same way it does in HTTP mode, so dispatch behavior is uniform
295
+ * across transports.
296
+ */
297
+ private startStdioTransport;
298
+ /**
299
+ * Tear down the MCP server and any open transports.
300
+ *
301
+ * Called during graceful shutdown. Idempotent — KickJS may invoke
302
+ * `shutdown` more than once under error conditions.
303
+ */
304
+ shutdown(): Promise<void>;
305
+ /**
306
+ * Return the list of tools discovered during startup.
307
+ *
308
+ * Primary consumers:
309
+ * - the `kick mcp --list` command
310
+ * - unit tests that verify a route was exposed as expected
311
+ */
312
+ getTools(): readonly McpToolDefinition[];
313
+ /**
314
+ * Build a `McpToolDefinition` for a single route, or return `null`
315
+ * if the route should be skipped under the current exposure mode.
316
+ *
317
+ * Scoping rules:
318
+ * - `explicit` (default): only routes with `@McpTool` are exposed
319
+ * - `auto`: every route is exposed, filtered by `include` /
320
+ * `exclude`; a `hidden: true` on `@McpTool` still drops the route
321
+ */
322
+ private tryBuildTool;
323
+ /**
324
+ * Derive a default description for routes exposed in `auto` mode
325
+ * without an explicit `@McpTool` decorator. Kept intentionally
326
+ * generic — teams running `auto` should still add `@McpTool` with
327
+ * real descriptions for any tool the model is expected to call
328
+ * reliably.
329
+ */
330
+ private deriveDescription;
331
+ /**
332
+ * Join a module mount path with the route-level sub-path.
333
+ *
334
+ * Mount path already includes the API prefix + version (e.g.
335
+ * `/api/v1/tasks`); the route-level `path` is relative (e.g. `/:id`).
336
+ * Trailing/leading slashes are normalized so the final URL is stable.
337
+ */
338
+ private joinMountPath;
339
+ /**
340
+ * Construct the underlying `McpServer` and register every discovered
341
+ * tool against it. The SDK accepts Zod schemas natively, so we pass
342
+ * `zodInputSchema` straight through and skip the JSON Schema form
343
+ * here (the JSON Schema is for inspection / docs).
344
+ *
345
+ * Tool calls dispatch through the Express pipeline via internal HTTP
346
+ * requests against the running server's address (captured in
347
+ * `afterStart`). This preserves middleware, validation, auth, and
348
+ * logging — tool calls behave exactly like external HTTP requests
349
+ * to the same route.
350
+ */
351
+ private buildMcpServer;
352
+ /**
353
+ * Dispatch a tool call through the Express pipeline.
354
+ *
355
+ * Builds an HTTP request that matches the tool's underlying route
356
+ * (method + path + body or query string from the args) and sends it
357
+ * to the running server's `serverBaseUrl`. The request goes through
358
+ * every middleware the route normally hits — auth, validation,
359
+ * logging, error handling — so tool calls observe exactly the same
360
+ * guarantees as external HTTP clients.
361
+ *
362
+ * Path parameters (e.g. `/:id`) are substituted from `args` before
363
+ * the request fires; matching keys are removed from the body/query
364
+ * to avoid sending them twice.
365
+ *
366
+ * Returns a `CallToolResult` whose `content` contains the response
367
+ * body as text. Non-2xx responses are flagged with `isError: true`
368
+ * so the calling LLM can react.
369
+ */
370
+ private dispatchTool;
371
+ /**
372
+ * Substitute Express-style path parameters (`:id`) in `mountPath`
373
+ * with values from `args`. Returns the resolved path plus the args
374
+ * that were NOT consumed by parameters, so they can be sent as the
375
+ * request body or query string.
376
+ *
377
+ * If a `:param` is referenced in the path but missing from args,
378
+ * the placeholder is left in place — the request will hit a 404 from
379
+ * the underlying route, which is reported back as an MCP error.
380
+ */
381
+ private substitutePathParams;
382
+ /**
383
+ * Resolve the running server's base URL from a Node `http.Server`
384
+ * instance. Returns null if the server isn't listening or its
385
+ * address can't be determined (e.g. when the adapter is mounted
386
+ * standalone for testing).
387
+ *
388
+ * IPv6 addresses are wrapped in brackets per RFC 3986. The hostname
389
+ * `0.0.0.0` (Linux default) is rewritten to `127.0.0.1` because the
390
+ * former is not a valid request target on all platforms.
391
+ */
392
+ private resolveServerBaseUrl;
393
+ /**
394
+ * Mount the StreamableHTTP transport endpoints on the existing
395
+ * Express app. The transport handles three HTTP verbs at a single
396
+ * URL:
397
+ * - POST: client → server messages (initialize, tool calls, etc.)
398
+ * - GET: server → client SSE stream for notifications
399
+ * - DELETE: client tells the server to terminate a session
400
+ *
401
+ * We mount all three on `${basePath}/messages` so a single URL is
402
+ * the entire MCP surface area.
403
+ */
404
+ private mountHttpRoutes;
405
+ }
406
+ //#endregion
407
+ //#region src/decorators.d.ts
408
+ /**
409
+ * Mark a controller method as an MCP tool.
410
+ *
411
+ * The adapter scans for this decorator at startup and registers the
412
+ * method as a callable tool on the MCP server. The input schema is
413
+ * inferred from the route's `body` Zod schema — you don't repeat it here.
414
+ *
415
+ * @example
416
+ * ```ts
417
+ * import { Controller, Post, type Ctx } from '@forinda/kickjs'
418
+ * import { McpTool } from '@forinda/kickjs-mcp'
419
+ * import { createTaskSchema } from './dtos/create-task.dto'
420
+ *
421
+ * @Controller('/tasks')
422
+ * export class TaskController {
423
+ * @Post('/', { body: createTaskSchema, name: 'CreateTask' })
424
+ * @McpTool({ description: 'Create a new task' })
425
+ * create(ctx: Ctx<KickRoutes.TaskController['create']>) {
426
+ * // ... existing handler ...
427
+ * }
428
+ * }
429
+ * ```
430
+ *
431
+ * In `explicit` mode (the default), only methods with this decorator
432
+ * are exposed as tools. In `auto` mode, it still controls the human-
433
+ * readable description and examples shown to the model.
434
+ */
435
+ declare function McpTool(options: McpToolOptions): MethodDecorator;
436
+ /**
437
+ * Read the MCP tool metadata attached to a method, if any.
438
+ *
439
+ * Returns `undefined` if the method was not decorated with `@McpTool`.
440
+ * The adapter uses this during the startup scan to decide whether a
441
+ * route should be registered as a tool.
442
+ */
443
+ declare function getMcpToolMeta(target: object, method: string): McpToolOptions | undefined;
444
+ /** Check whether a method was decorated with `@McpTool`. */
445
+ declare function isMcpTool(target: object, method: string): boolean;
446
+ //#endregion
447
+ //#region src/constants.d.ts
448
+ /**
449
+ * Metadata key for the `@McpTool` decorator.
450
+ *
451
+ * Using `createToken` gives a collision-safe, type-carrying identifier:
452
+ * the phantom type parameter flows through `getMethodMetaOrUndefined`
453
+ * so consumers get `McpToolOptions` back without a manual cast, and
454
+ * reference-equality guarantees that two separate definitions of
455
+ * `MCP_TOOL_METADATA` can never shadow each other even if the package
456
+ * is loaded more than once.
457
+ */
458
+ declare const MCP_TOOL_METADATA: any;
459
+ //#endregion
460
+ export { MCP_TOOL_METADATA, McpAdapter, type McpAdapterOptions, type McpAuthOptions, type McpExposureMode, McpTool, type McpToolDefinition, type McpToolExample, type McpToolOptions, type McpTransport, getMcpToolMeta, isMcpTool };
461
+ //# sourceMappingURL=index.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.mts","names":[],"sources":["../src/types.ts","../src/mcp.adapter.ts","../src/decorators.ts","../src/constants.ts"],"mappings":";;;;;;;;AAaA;;;;;AAWA;;;KAXY,YAAA;;AAoBZ;;;;;;;;KATY,eAAA;;AA8BZ;;;;;;UArBiB,cAAA;EAqCM;EAnCrB,IAAA;EAqBA;EAnBA,QAAA,GAAW,KAAA,uBAA4B,OAAA;AAAA;;;;;;;;;;;;;AA4CzC;;UA3BiB,iBAAA;EA+BH;EA7BZ,IAAA;EA6BA;EA3BA,OAAA;EA6BA;EA3BA,WAAA;EA2BM;EAzBN,IAAA,GAAO,eAAA;EAyCsB;EAvC7B,SAAA,GAAY,YAAA;EAwDE;EAtDd,OAAA,GAAU,KAAA;EA4DC;EA1DX,OAAA;EA0DyB;EAxDzB,IAAA,GAAO,cAAA;EA6CP;EA3CA,QAAA;AAAA;;;;;;;UASe,cAAA;EAmEA;EAjEf,WAAA;;EAEA,IAAA,EAAM,MAAA;EA8ES;EA5Ef,MAAA;AAAA;;;;;;;;;;;;;;UAgBe,cAAA;;;;ACxCjB;ED6CE,IAAA;;;;;;;EAOA,WAAA;ECkKqB;;;;ED7JrB,WAAA,GAAc,UAAA;ECxDL;;;ED4DT,YAAA,GAAe,UAAA;EC3CP;ED6CR,QAAA,GAAW,cAAA;ECzBH;;;;;ED+BR,MAAA;AAAA;;;;;;;;;;;;;;UAgBe,iBAAA;ECgJP;ED9IR,IAAA;EC+MQ;ED7MR,WAAA;ECqRc;EDnRd,WAAA,EAAa,MAAA;ECiYL;;;;;;ED1XR,cAAA;EElIc;EFoId,YAAA,GAAe,MAAA;;EAEf,UAAA;EEtI+B;EFwI/B,SAAA;EExIgD;EF0IhD,QAAA,GAAW,cAAA;AAAA;;;;;AA5Jb;;;;;AAWA;;;;;AASA;;;;;;;;;;AAqBA;;;;;;;;;;;;;;;;;;;;cCSa,UAAA,YAAsB,UAAA;EAAA,SACxB,IAAA;EAAA,iBAEQ,OAAA;EDMT;EAAA,iBCAS,kBAAA;EDSY;EAAA,iBCHZ,KAAA;EDOL;EAAA,QCJJ,SAAA;EDIR;;;;;AAkBF;EAlBE,QCIQ,SAAA;;;;;;;;;;;UAYA,aAAA;cAEI,OAAA,EAAS,iBAAA;EDuBrB;;;;;AAsBF;;EC5BE,YAAA,CAAa,UAAA,EAAY,WAAA,EAAa,SAAA;EDkCzB;;;;;;;;ECtBb,WAAA,CAAY,IAAA,EAAM,cAAA;ED6BlB;;;;;;;;;;;;AClGF;;EAkGQ,UAAA,CAAW,GAAA,EAAK,cAAA,GAAiB,OAAA;EA1DlB;;;;;;;;;EAAA,QAiHb,oBAAA;EAzJyB;;;;;;;;;;;;;;EAAA,QA8KnB,mBAAA;EAzGd;;;;;;EAyHM,QAAA,CAAA,GAAY,OAAA;EArCV;;;;;;;EA6DR,QAAA,CAAA,YAAqB,iBAAA;EAgFb;;;;;;;;;EAAA,QAjEA,YAAA;;ACrQV;;;;;;UD2TU,iBAAA;EC3TuD;;AAajE;;;;;EAbiE,QDsUvD,aAAA;ECzTsD;;;AAKhE;;;;;;;;ACpCA;ED+BgE,QD4UtD,cAAA;;;;;;;;;;;;;;;;;;;UAqDM,YAAA;;;;;;;;;;;UAoFN,oBAAA;;;;;;;;;;;UA0BA,oBAAA;;;;;;;;;;;;UAqBA,eAAA;AAAA;;;;;;ADniBV;;;;;AAWA;;;;;AASA;;;;;;;;;;AAqBA;;;;iBEvBgB,OAAA,CAAQ,OAAA,EAAS,cAAA,GAAiB,eAAA;;;;;;;;iBAalC,cAAA,CAAe,MAAA,UAAgB,MAAA,WAAiB,cAAA;;iBAKhD,SAAA,CAAU,MAAA,UAAgB,MAAA;;;;;;;AFpC1C;;;;;AAWA;cGXa,iBAAA"}
package/dist/index.mjs ADDED
@@ -0,0 +1,552 @@
1
+ /**
2
+ * @forinda/kickjs-mcp v2.3.0
3
+ *
4
+ * Copyright (c) Felix Orinda
5
+ *
6
+ * This source code is licensed under the MIT license found in the
7
+ * LICENSE file in the root directory of this source tree.
8
+ *
9
+ * @license MIT
10
+ */
11
+ import { randomUUID } from "node:crypto";
12
+ import { Logger, METADATA, createToken, getClassMeta, getMethodMetaOrUndefined, setMethodMeta } from "@forinda/kickjs";
13
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
14
+ import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
15
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
16
+ //#region src/constants.ts
17
+ /**
18
+ * Metadata key for the `@McpTool` decorator.
19
+ *
20
+ * Using `createToken` gives a collision-safe, type-carrying identifier:
21
+ * the phantom type parameter flows through `getMethodMetaOrUndefined`
22
+ * so consumers get `McpToolOptions` back without a manual cast, and
23
+ * reference-equality guarantees that two separate definitions of
24
+ * `MCP_TOOL_METADATA` can never shadow each other even if the package
25
+ * is loaded more than once.
26
+ */
27
+ const MCP_TOOL_METADATA = createToken("kickjs.mcp.tool");
28
+ //#endregion
29
+ //#region src/decorators.ts
30
+ /**
31
+ * Mark a controller method as an MCP tool.
32
+ *
33
+ * The adapter scans for this decorator at startup and registers the
34
+ * method as a callable tool on the MCP server. The input schema is
35
+ * inferred from the route's `body` Zod schema — you don't repeat it here.
36
+ *
37
+ * @example
38
+ * ```ts
39
+ * import { Controller, Post, type Ctx } from '@forinda/kickjs'
40
+ * import { McpTool } from '@forinda/kickjs-mcp'
41
+ * import { createTaskSchema } from './dtos/create-task.dto'
42
+ *
43
+ * @Controller('/tasks')
44
+ * export class TaskController {
45
+ * @Post('/', { body: createTaskSchema, name: 'CreateTask' })
46
+ * @McpTool({ description: 'Create a new task' })
47
+ * create(ctx: Ctx<KickRoutes.TaskController['create']>) {
48
+ * // ... existing handler ...
49
+ * }
50
+ * }
51
+ * ```
52
+ *
53
+ * In `explicit` mode (the default), only methods with this decorator
54
+ * are exposed as tools. In `auto` mode, it still controls the human-
55
+ * readable description and examples shown to the model.
56
+ */
57
+ function McpTool(options) {
58
+ return (target, propertyKey) => {
59
+ setMethodMeta(MCP_TOOL_METADATA, options, target, propertyKey);
60
+ };
61
+ }
62
+ /**
63
+ * Read the MCP tool metadata attached to a method, if any.
64
+ *
65
+ * Returns `undefined` if the method was not decorated with `@McpTool`.
66
+ * The adapter uses this during the startup scan to decide whether a
67
+ * route should be registered as a tool.
68
+ */
69
+ function getMcpToolMeta(target, method) {
70
+ return getMethodMetaOrUndefined(MCP_TOOL_METADATA, target, method);
71
+ }
72
+ /** Check whether a method was decorated with `@McpTool`. */
73
+ function isMcpTool(target, method) {
74
+ return getMcpToolMeta(target, method) !== void 0;
75
+ }
76
+ //#endregion
77
+ //#region src/zod-to-json-schema.ts
78
+ /**
79
+ * Minimal Zod v4+ schema parser.
80
+ *
81
+ * Mirrors the `zodSchemaParser` in `@forinda/kickjs-swagger`. Zod v4
82
+ * ships with a native `.toJSONSchema()` instance method, so this is
83
+ * just a type guard + a call.
84
+ *
85
+ * Kept in-package (rather than importing from Swagger) so the MCP
86
+ * adapter has no dependency on the Swagger package. If KickJS ever
87
+ * extracts a shared `@forinda/kickjs-schema` utility, both adapters
88
+ * can switch to it in one PR.
89
+ */
90
+ /**
91
+ * Check whether a value looks like a Zod v4+ schema.
92
+ *
93
+ * Uses structural duck-typing: the object has `safeParse` (all Zod
94
+ * versions) AND `toJSONSchema` (Zod v4+). This avoids importing Zod
95
+ * as a value, which would force it to become a runtime dep.
96
+ */
97
+ function isZodSchema(schema) {
98
+ return schema != null && typeof schema === "object" && typeof schema.safeParse === "function" && typeof schema.toJSONSchema === "function";
99
+ }
100
+ /**
101
+ * Convert a Zod v4+ schema to a JSON Schema object, stripping the
102
+ * top-level `$schema` key so the output can be embedded inside an
103
+ * MCP tool definition directly.
104
+ *
105
+ * Returns `null` if the input doesn't look like a Zod schema. Callers
106
+ * should fall back to an empty-object input schema in that case.
107
+ */
108
+ function zodToJsonSchema(schema) {
109
+ if (!isZodSchema(schema)) return null;
110
+ const { $schema: _ignored, ...rest } = schema.toJSONSchema();
111
+ return rest;
112
+ }
113
+ //#endregion
114
+ //#region src/mcp.adapter.ts
115
+ const log = Logger.for("McpAdapter");
116
+ /**
117
+ * Expose a KickJS application as a Model Context Protocol (MCP) server.
118
+ *
119
+ * The adapter implements `onRouteMount` to collect every registered
120
+ * controller alongside its mount path. During `beforeStart` (after all
121
+ * modules have finished mounting), it walks the collected controllers,
122
+ * reads route metadata via `getClassMeta(METADATA.ROUTES, ...)`, and
123
+ * builds a `McpToolDefinition[]` that the MCP SDK will register as
124
+ * callable tools.
125
+ *
126
+ * The input schema of each tool is the JSON Schema equivalent of the
127
+ * route's Zod `body` schema, converted via the package's own
128
+ * `zod-to-json-schema` helper. Tools with no body schema get an empty
129
+ * object schema so the model can still call them with no arguments.
130
+ *
131
+ * @example
132
+ * ```ts
133
+ * import { bootstrap } from '@forinda/kickjs'
134
+ * import { McpAdapter } from '@forinda/kickjs-mcp'
135
+ * import { modules } from './modules'
136
+ *
137
+ * export const app = await bootstrap({
138
+ * modules,
139
+ * adapters: [
140
+ * new McpAdapter({
141
+ * name: 'task-api',
142
+ * version: '1.0.0',
143
+ * description: 'Task management MCP server',
144
+ * mode: 'explicit',
145
+ * transport: 'sse',
146
+ * }),
147
+ * ],
148
+ * })
149
+ * ```
150
+ *
151
+ * @remarks
152
+ * Tool discovery is complete. The remaining work for v1 is wiring the
153
+ * generated tool definitions to `@modelcontextprotocol/sdk` in
154
+ * `afterStart` — see the TODO markers in that hook. Until then,
155
+ * `getTools()` returns the discovered definitions so tests can assert
156
+ * the scan produced the expected shape.
157
+ */
158
+ var McpAdapter = class {
159
+ name = "McpAdapter";
160
+ options;
161
+ /** Controllers collected during the mount phase, in insertion order. */
162
+ mountedControllers = [];
163
+ /** Discovered tool definitions, built during `beforeStart`. */
164
+ tools = [];
165
+ /** Active MCP server instance, created in `afterStart`. */
166
+ mcpServer = null;
167
+ /**
168
+ * Active MCP transport, created in `afterStart`. Can be either a
169
+ * `StreamableHTTPServerTransport` (the default for HTTP-based MCP)
170
+ * or a `StdioServerTransport` (when running via the `kick mcp` CLI
171
+ * or with `KICK_MCP_STDIO=1`).
172
+ */
173
+ transport = null;
174
+ /**
175
+ * Base URL of the running KickJS HTTP server, captured in `afterStart`
176
+ * once the server is listening. Tool dispatch makes internal HTTP
177
+ * requests against this base URL so calls flow through the normal
178
+ * Express pipeline (middleware, validation, auth, logging, error
179
+ * handling) rather than bypassing it.
180
+ *
181
+ * Format: `http://127.0.0.1:<port>`. Set to `null` until afterStart
182
+ * runs and reset to `null` on shutdown.
183
+ */
184
+ serverBaseUrl = null;
185
+ constructor(options) {
186
+ this.options = {
187
+ mode: options.mode ?? "explicit",
188
+ transport: options.transport ?? "sse",
189
+ basePath: options.basePath ?? "/_mcp",
190
+ version: options.version ?? "0.0.0",
191
+ ...options
192
+ };
193
+ }
194
+ /**
195
+ * Called by the framework each time a module mounts a controller.
196
+ *
197
+ * We don't inspect routes here — we just record the pair and process
198
+ * everything in `beforeStart` once mounting is fully complete. This
199
+ * keeps the scan logic in one place and makes it easier to unit test.
200
+ */
201
+ onRouteMount(controller, mountPath) {
202
+ this.mountedControllers.push({
203
+ controller,
204
+ mountPath
205
+ });
206
+ }
207
+ /**
208
+ * Walk collected controllers, read route metadata, and materialize
209
+ * `McpToolDefinition[]`.
210
+ *
211
+ * Runs after every module has mounted but before the HTTP server
212
+ * starts listening, so the MCP server can be initialized in
213
+ * `afterStart` with a complete tool list.
214
+ */
215
+ beforeStart(_ctx) {
216
+ for (const { controller, mountPath } of this.mountedControllers) {
217
+ const routes = getClassMeta(METADATA.ROUTES, controller, []);
218
+ for (const route of routes) {
219
+ const tool = this.tryBuildTool(controller, mountPath, route);
220
+ if (tool) this.tools.push(tool);
221
+ }
222
+ }
223
+ log.debug(`MCP adapter discovered ${this.tools.length} tool(s) (mode=${this.options.mode}, transport=${this.options.transport})`);
224
+ }
225
+ /**
226
+ * Start the MCP server on the configured transport.
227
+ *
228
+ * - `http` (recommended): mounts a `StreamableHTTPServerTransport` on
229
+ * the existing Express app at `${basePath}/messages`. This is the
230
+ * modern, spec-compliant way to expose MCP over HTTP.
231
+ * - `sse` (deprecated): currently aliases to `http` and emits a warning.
232
+ * The MCP SSE transport class is deprecated upstream in favor of
233
+ * StreamableHTTP, which already supports SSE-style streaming under
234
+ * the hood.
235
+ * - `stdio`: skipped here. The standalone `kick mcp` CLI command
236
+ * instantiates the adapter directly and connects it to a stdio
237
+ * transport so dev logs don't interfere.
238
+ */
239
+ async afterStart(ctx) {
240
+ this.serverBaseUrl = this.resolveServerBaseUrl(ctx.server);
241
+ const effectiveTransport = this.resolveTransportMode();
242
+ if (effectiveTransport === "stdio") {
243
+ await this.startStdioTransport();
244
+ return;
245
+ }
246
+ if (effectiveTransport === "sse") log.warn("sse transport is deprecated upstream; using StreamableHTTP transport, which supports the same SSE wire format under the hood");
247
+ const expressApp = ctx.app;
248
+ if (!expressApp) {
249
+ log.warn("McpAdapter: AdapterContext.app is unavailable, cannot mount HTTP transport");
250
+ return;
251
+ }
252
+ this.mcpServer = this.buildMcpServer();
253
+ const httpTransport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID() });
254
+ this.transport = httpTransport;
255
+ await this.mcpServer.connect(httpTransport);
256
+ this.mountHttpRoutes(expressApp, httpTransport);
257
+ log.info(`McpAdapter ready — ${this.tools.length} tool(s) registered, listening at ${this.options.basePath}/messages`);
258
+ }
259
+ /**
260
+ * Decide which transport to actually start.
261
+ *
262
+ * Precedence: an explicit `KICK_MCP_STDIO=1` environment variable
263
+ * always wins, because that's how the `kick mcp` CLI command tells
264
+ * the running process to switch to stdio mode without requiring the
265
+ * user to edit their bootstrap. Otherwise the constructor option is
266
+ * honored as-is.
267
+ */
268
+ resolveTransportMode() {
269
+ if (process.env.KICK_MCP_STDIO === "1" || process.env.KICK_MCP_STDIO === "true") return "stdio";
270
+ return this.options.transport;
271
+ }
272
+ /**
273
+ * Start the MCP server bound to process stdio.
274
+ *
275
+ * Used by the `kick mcp` CLI: the parent process pipes its stdin/
276
+ * stdout to this adapter so MCP clients (Claude Code, Cursor) can
277
+ * speak the protocol over the wire. Logs MUST go to stderr in this
278
+ * mode — anything written to stdout corrupts the JSON-RPC stream.
279
+ *
280
+ * The HTTP server is still running (the framework called start()
281
+ * before afterStart), but we don't mount the MCP routes on it. Tool
282
+ * dispatch routes through fetch against `serverBaseUrl` exactly the
283
+ * same way it does in HTTP mode, so dispatch behavior is uniform
284
+ * across transports.
285
+ */
286
+ async startStdioTransport() {
287
+ this.mcpServer = this.buildMcpServer();
288
+ this.transport = new StdioServerTransport();
289
+ await this.mcpServer.connect(this.transport);
290
+ log.info(`McpAdapter ready (stdio) — ${this.tools.length} tool(s) registered, dispatching against ${this.serverBaseUrl ?? "unknown"}`);
291
+ }
292
+ /**
293
+ * Tear down the MCP server and any open transports.
294
+ *
295
+ * Called during graceful shutdown. Idempotent — KickJS may invoke
296
+ * `shutdown` more than once under error conditions.
297
+ */
298
+ async shutdown() {
299
+ try {
300
+ await this.transport?.close();
301
+ } catch (err) {
302
+ log.error(err, "McpAdapter: failed to close transport");
303
+ }
304
+ try {
305
+ await this.mcpServer?.close();
306
+ } catch (err) {
307
+ log.error(err, "McpAdapter: failed to close server");
308
+ }
309
+ this.transport = null;
310
+ this.mcpServer = null;
311
+ this.serverBaseUrl = null;
312
+ log.debug("McpAdapter shutdown complete");
313
+ }
314
+ /**
315
+ * Return the list of tools discovered during startup.
316
+ *
317
+ * Primary consumers:
318
+ * - the `kick mcp --list` command
319
+ * - unit tests that verify a route was exposed as expected
320
+ */
321
+ getTools() {
322
+ return this.tools;
323
+ }
324
+ /**
325
+ * Build a `McpToolDefinition` for a single route, or return `null`
326
+ * if the route should be skipped under the current exposure mode.
327
+ *
328
+ * Scoping rules:
329
+ * - `explicit` (default): only routes with `@McpTool` are exposed
330
+ * - `auto`: every route is exposed, filtered by `include` /
331
+ * `exclude`; a `hidden: true` on `@McpTool` still drops the route
332
+ */
333
+ tryBuildTool(controller, mountPath, route) {
334
+ const meta = getMcpToolMeta(controller.prototype, route.handlerName);
335
+ if (this.options.mode === "explicit" && !meta) return null;
336
+ if (meta?.hidden) return null;
337
+ if (this.options.mode === "auto") {
338
+ const methodUpper = route.method.toUpperCase();
339
+ if (this.options.include && !this.options.include.includes(methodUpper)) return null;
340
+ if (this.options.exclude?.some((prefix) => mountPath.startsWith(prefix))) return null;
341
+ }
342
+ const description = meta?.description ?? this.deriveDescription(controller, route);
343
+ const name = meta?.name ?? `${controller.name}.${route.handlerName}`;
344
+ const candidateSchema = meta?.inputSchema ?? route.validation?.body ?? route.validation?.query;
345
+ return {
346
+ name,
347
+ description,
348
+ inputSchema: zodToJsonSchema(candidateSchema) ?? {
349
+ type: "object",
350
+ properties: {},
351
+ additionalProperties: false
352
+ },
353
+ zodInputSchema: candidateSchema,
354
+ outputSchema: (meta?.outputSchema ? zodToJsonSchema(meta.outputSchema) : void 0) ?? void 0,
355
+ httpMethod: route.method.toUpperCase(),
356
+ mountPath: this.joinMountPath(mountPath, route.path),
357
+ examples: meta?.examples
358
+ };
359
+ }
360
+ /**
361
+ * Derive a default description for routes exposed in `auto` mode
362
+ * without an explicit `@McpTool` decorator. Kept intentionally
363
+ * generic — teams running `auto` should still add `@McpTool` with
364
+ * real descriptions for any tool the model is expected to call
365
+ * reliably.
366
+ */
367
+ deriveDescription(controller, route) {
368
+ return `${route.method.toUpperCase()} handler ${controller.name}.${route.handlerName}`;
369
+ }
370
+ /**
371
+ * Join a module mount path with the route-level sub-path.
372
+ *
373
+ * Mount path already includes the API prefix + version (e.g.
374
+ * `/api/v1/tasks`); the route-level `path` is relative (e.g. `/:id`).
375
+ * Trailing/leading slashes are normalized so the final URL is stable.
376
+ */
377
+ joinMountPath(mountPath, routePath) {
378
+ const base = mountPath.endsWith("/") ? mountPath.slice(0, -1) : mountPath;
379
+ if (!routePath || routePath === "/") return base;
380
+ return `${base}${routePath.startsWith("/") ? routePath : `/${routePath}`}`;
381
+ }
382
+ /**
383
+ * Construct the underlying `McpServer` and register every discovered
384
+ * tool against it. The SDK accepts Zod schemas natively, so we pass
385
+ * `zodInputSchema` straight through and skip the JSON Schema form
386
+ * here (the JSON Schema is for inspection / docs).
387
+ *
388
+ * Tool calls dispatch through the Express pipeline via internal HTTP
389
+ * requests against the running server's address (captured in
390
+ * `afterStart`). This preserves middleware, validation, auth, and
391
+ * logging — tool calls behave exactly like external HTTP requests
392
+ * to the same route.
393
+ */
394
+ buildMcpServer() {
395
+ const server = new McpServer({
396
+ name: this.options.name,
397
+ version: this.options.version,
398
+ ...this.options.description ? { description: this.options.description } : {}
399
+ });
400
+ const registerTool = server.registerTool.bind(server);
401
+ for (const tool of this.tools) {
402
+ const config = { description: tool.description };
403
+ if (tool.zodInputSchema) config.inputSchema = tool.zodInputSchema;
404
+ registerTool(tool.name, config, async (args) => this.dispatchTool(tool, args));
405
+ }
406
+ return server;
407
+ }
408
+ /**
409
+ * Dispatch a tool call through the Express pipeline.
410
+ *
411
+ * Builds an HTTP request that matches the tool's underlying route
412
+ * (method + path + body or query string from the args) and sends it
413
+ * to the running server's `serverBaseUrl`. The request goes through
414
+ * every middleware the route normally hits — auth, validation,
415
+ * logging, error handling — so tool calls observe exactly the same
416
+ * guarantees as external HTTP clients.
417
+ *
418
+ * Path parameters (e.g. `/:id`) are substituted from `args` before
419
+ * the request fires; matching keys are removed from the body/query
420
+ * to avoid sending them twice.
421
+ *
422
+ * Returns a `CallToolResult` whose `content` contains the response
423
+ * body as text. Non-2xx responses are flagged with `isError: true`
424
+ * so the calling LLM can react.
425
+ */
426
+ async dispatchTool(tool, rawArgs) {
427
+ if (!this.serverBaseUrl) return {
428
+ isError: true,
429
+ content: [{
430
+ type: "text",
431
+ text: `Cannot dispatch ${tool.name}: HTTP server address not yet captured`
432
+ }]
433
+ };
434
+ const args = rawArgs ?? {};
435
+ const { path, remainingArgs } = this.substitutePathParams(tool.mountPath, args);
436
+ const method = tool.httpMethod.toUpperCase();
437
+ const hasBody = method === "POST" || method === "PUT" || method === "PATCH";
438
+ let url = `${this.serverBaseUrl}${path}`;
439
+ const init = {
440
+ method,
441
+ headers: {
442
+ accept: "application/json",
443
+ "x-mcp-tool": tool.name
444
+ }
445
+ };
446
+ if (hasBody) {
447
+ init.headers["content-type"] = "application/json";
448
+ init.body = JSON.stringify(remainingArgs);
449
+ } else if (Object.keys(remainingArgs).length > 0) {
450
+ const qs = new URLSearchParams();
451
+ for (const [key, value] of Object.entries(remainingArgs)) {
452
+ if (value === void 0 || value === null) continue;
453
+ qs.append(key, typeof value === "string" ? value : JSON.stringify(value));
454
+ }
455
+ const sep = url.includes("?") ? "&" : "?";
456
+ url = `${url}${sep}${qs.toString()}`;
457
+ }
458
+ try {
459
+ const res = await fetch(url, init);
460
+ const text = await res.text();
461
+ return {
462
+ isError: res.status >= 400,
463
+ content: [{
464
+ type: "text",
465
+ text: text || `(${res.status} ${res.statusText})`
466
+ }]
467
+ };
468
+ } catch (err) {
469
+ const message = err instanceof Error ? err.message : String(err);
470
+ log.error(err, `McpAdapter: dispatch failed for ${tool.name}`);
471
+ return {
472
+ isError: true,
473
+ content: [{
474
+ type: "text",
475
+ text: `Tool dispatch error: ${message}`
476
+ }]
477
+ };
478
+ }
479
+ }
480
+ /**
481
+ * Substitute Express-style path parameters (`:id`) in `mountPath`
482
+ * with values from `args`. Returns the resolved path plus the args
483
+ * that were NOT consumed by parameters, so they can be sent as the
484
+ * request body or query string.
485
+ *
486
+ * If a `:param` is referenced in the path but missing from args,
487
+ * the placeholder is left in place — the request will hit a 404 from
488
+ * the underlying route, which is reported back as an MCP error.
489
+ */
490
+ substitutePathParams(mountPath, args) {
491
+ const remaining = { ...args };
492
+ return {
493
+ path: mountPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_match, param) => {
494
+ if (param in remaining) {
495
+ const value = remaining[param];
496
+ delete remaining[param];
497
+ return encodeURIComponent(String(value));
498
+ }
499
+ return `:${param}`;
500
+ }),
501
+ remainingArgs: remaining
502
+ };
503
+ }
504
+ /**
505
+ * Resolve the running server's base URL from a Node `http.Server`
506
+ * instance. Returns null if the server isn't listening or its
507
+ * address can't be determined (e.g. when the adapter is mounted
508
+ * standalone for testing).
509
+ *
510
+ * IPv6 addresses are wrapped in brackets per RFC 3986. The hostname
511
+ * `0.0.0.0` (Linux default) is rewritten to `127.0.0.1` because the
512
+ * former is not a valid request target on all platforms.
513
+ */
514
+ resolveServerBaseUrl(server) {
515
+ if (!server) return null;
516
+ const address = server.address();
517
+ if (!address || typeof address === "string") return null;
518
+ let host = address.address;
519
+ if (host === "::" || host === "0.0.0.0" || host === "") host = "127.0.0.1";
520
+ if (host.includes(":") && !host.startsWith("[")) host = `[${host}]`;
521
+ return `http://${host}:${address.port}`;
522
+ }
523
+ /**
524
+ * Mount the StreamableHTTP transport endpoints on the existing
525
+ * Express app. The transport handles three HTTP verbs at a single
526
+ * URL:
527
+ * - POST: client → server messages (initialize, tool calls, etc.)
528
+ * - GET: server → client SSE stream for notifications
529
+ * - DELETE: client tells the server to terminate a session
530
+ *
531
+ * We mount all three on `${basePath}/messages` so a single URL is
532
+ * the entire MCP surface area.
533
+ */
534
+ mountHttpRoutes(app, transport) {
535
+ const path = `${this.options.basePath}/messages`;
536
+ const handleRequest = async (req, res) => {
537
+ try {
538
+ await transport.handleRequest(req, res, req.body);
539
+ } catch (err) {
540
+ log.error(err, `McpAdapter: error handling ${req.method} ${path}`);
541
+ if (!res.headersSent) res.status(500).json({ error: "MCP transport error" });
542
+ }
543
+ };
544
+ app.post(path, handleRequest);
545
+ app.get(path, handleRequest);
546
+ app.delete(path, handleRequest);
547
+ }
548
+ };
549
+ //#endregion
550
+ export { MCP_TOOL_METADATA, McpAdapter, McpTool, getMcpToolMeta, isMcpTool };
551
+
552
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../src/constants.ts","../src/decorators.ts","../src/zod-to-json-schema.ts","../src/mcp.adapter.ts"],"sourcesContent":["import { createToken } from '@forinda/kickjs'\nimport type { McpToolOptions } from './types'\n\n/**\n * Metadata key for the `@McpTool` decorator.\n *\n * Using `createToken` gives a collision-safe, type-carrying identifier:\n * the phantom type parameter flows through `getMethodMetaOrUndefined`\n * so consumers get `McpToolOptions` back without a manual cast, and\n * reference-equality guarantees that two separate definitions of\n * `MCP_TOOL_METADATA` can never shadow each other even if the package\n * is loaded more than once.\n */\nexport const MCP_TOOL_METADATA = createToken<McpToolOptions>('kickjs.mcp.tool')\n","import { setMethodMeta, getMethodMetaOrUndefined } from '@forinda/kickjs'\nimport { MCP_TOOL_METADATA } from './constants'\nimport type { McpToolOptions } from './types'\n\n/**\n * Mark a controller method as an MCP tool.\n *\n * The adapter scans for this decorator at startup and registers the\n * method as a callable tool on the MCP server. The input schema is\n * inferred from the route's `body` Zod schema — you don't repeat it here.\n *\n * @example\n * ```ts\n * import { Controller, Post, type Ctx } from '@forinda/kickjs'\n * import { McpTool } from '@forinda/kickjs-mcp'\n * import { createTaskSchema } from './dtos/create-task.dto'\n *\n * @Controller('/tasks')\n * export class TaskController {\n * @Post('/', { body: createTaskSchema, name: 'CreateTask' })\n * @McpTool({ description: 'Create a new task' })\n * create(ctx: Ctx<KickRoutes.TaskController['create']>) {\n * // ... existing handler ...\n * }\n * }\n * ```\n *\n * In `explicit` mode (the default), only methods with this decorator\n * are exposed as tools. In `auto` mode, it still controls the human-\n * readable description and examples shown to the model.\n */\nexport function McpTool(options: McpToolOptions): MethodDecorator {\n return (target, propertyKey) => {\n setMethodMeta(MCP_TOOL_METADATA, options, target, propertyKey as string)\n }\n}\n\n/**\n * Read the MCP tool metadata attached to a method, if any.\n *\n * Returns `undefined` if the method was not decorated with `@McpTool`.\n * The adapter uses this during the startup scan to decide whether a\n * route should be registered as a tool.\n */\nexport function getMcpToolMeta(target: object, method: string): McpToolOptions | undefined {\n return getMethodMetaOrUndefined<McpToolOptions>(MCP_TOOL_METADATA, target, method)\n}\n\n/** Check whether a method was decorated with `@McpTool`. */\nexport function isMcpTool(target: object, method: string): boolean {\n return getMcpToolMeta(target, method) !== undefined\n}\n","/**\n * Minimal Zod v4+ schema parser.\n *\n * Mirrors the `zodSchemaParser` in `@forinda/kickjs-swagger`. Zod v4\n * ships with a native `.toJSONSchema()` instance method, so this is\n * just a type guard + a call.\n *\n * Kept in-package (rather than importing from Swagger) so the MCP\n * adapter has no dependency on the Swagger package. If KickJS ever\n * extracts a shared `@forinda/kickjs-schema` utility, both adapters\n * can switch to it in one PR.\n */\n\n/**\n * Check whether a value looks like a Zod v4+ schema.\n *\n * Uses structural duck-typing: the object has `safeParse` (all Zod\n * versions) AND `toJSONSchema` (Zod v4+). This avoids importing Zod\n * as a value, which would force it to become a runtime dep.\n */\nexport function isZodSchema(schema: unknown): boolean {\n return (\n schema != null &&\n typeof schema === 'object' &&\n typeof (schema as { safeParse?: unknown }).safeParse === 'function' &&\n typeof (schema as { toJSONSchema?: unknown }).toJSONSchema === 'function'\n )\n}\n\n/**\n * Convert a Zod v4+ schema to a JSON Schema object, stripping the\n * top-level `$schema` key so the output can be embedded inside an\n * MCP tool definition directly.\n *\n * Returns `null` if the input doesn't look like a Zod schema. Callers\n * should fall back to an empty-object input schema in that case.\n */\nexport function zodToJsonSchema(schema: unknown): Record<string, unknown> | null {\n if (!isZodSchema(schema)) return null\n const { $schema: _ignored, ...rest } = (\n schema as { toJSONSchema: () => Record<string, unknown> }\n ).toJSONSchema() as Record<string, unknown>\n return rest\n}\n","import { randomUUID } from 'node:crypto'\nimport {\n Logger,\n METADATA,\n getClassMeta,\n type AppAdapter,\n type AdapterContext,\n type Constructor,\n type RouteDefinition,\n} from '@forinda/kickjs'\nimport type { Express } from 'express'\nimport { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'\nimport { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'\nimport type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js'\nimport { getMcpToolMeta } from './decorators'\nimport { zodToJsonSchema } from './zod-to-json-schema'\nimport type { McpAdapterOptions, McpToolDefinition, McpTransport } from './types'\n\nconst log = Logger.for('McpAdapter')\n\n/**\n * Expose a KickJS application as a Model Context Protocol (MCP) server.\n *\n * The adapter implements `onRouteMount` to collect every registered\n * controller alongside its mount path. During `beforeStart` (after all\n * modules have finished mounting), it walks the collected controllers,\n * reads route metadata via `getClassMeta(METADATA.ROUTES, ...)`, and\n * builds a `McpToolDefinition[]` that the MCP SDK will register as\n * callable tools.\n *\n * The input schema of each tool is the JSON Schema equivalent of the\n * route's Zod `body` schema, converted via the package's own\n * `zod-to-json-schema` helper. Tools with no body schema get an empty\n * object schema so the model can still call them with no arguments.\n *\n * @example\n * ```ts\n * import { bootstrap } from '@forinda/kickjs'\n * import { McpAdapter } from '@forinda/kickjs-mcp'\n * import { modules } from './modules'\n *\n * export const app = await bootstrap({\n * modules,\n * adapters: [\n * new McpAdapter({\n * name: 'task-api',\n * version: '1.0.0',\n * description: 'Task management MCP server',\n * mode: 'explicit',\n * transport: 'sse',\n * }),\n * ],\n * })\n * ```\n *\n * @remarks\n * Tool discovery is complete. The remaining work for v1 is wiring the\n * generated tool definitions to `@modelcontextprotocol/sdk` in\n * `afterStart` — see the TODO markers in that hook. Until then,\n * `getTools()` returns the discovered definitions so tests can assert\n * the scan produced the expected shape.\n */\nexport class McpAdapter implements AppAdapter {\n readonly name = 'McpAdapter'\n\n private readonly options: Required<\n Pick<McpAdapterOptions, 'mode' | 'transport' | 'basePath' | 'version'>\n > &\n McpAdapterOptions\n\n /** Controllers collected during the mount phase, in insertion order. */\n private readonly mountedControllers: Array<{\n controller: Constructor\n mountPath: string\n }> = []\n\n /** Discovered tool definitions, built during `beforeStart`. */\n private readonly tools: McpToolDefinition[] = []\n\n /** Active MCP server instance, created in `afterStart`. */\n private mcpServer: McpServer | null = null\n\n /**\n * Active MCP transport, created in `afterStart`. Can be either a\n * `StreamableHTTPServerTransport` (the default for HTTP-based MCP)\n * or a `StdioServerTransport` (when running via the `kick mcp` CLI\n * or with `KICK_MCP_STDIO=1`).\n */\n private transport: Transport | null = null\n\n /**\n * Base URL of the running KickJS HTTP server, captured in `afterStart`\n * once the server is listening. Tool dispatch makes internal HTTP\n * requests against this base URL so calls flow through the normal\n * Express pipeline (middleware, validation, auth, logging, error\n * handling) rather than bypassing it.\n *\n * Format: `http://127.0.0.1:<port>`. Set to `null` until afterStart\n * runs and reset to `null` on shutdown.\n */\n private serverBaseUrl: string | null = null\n\n constructor(options: McpAdapterOptions) {\n this.options = {\n mode: options.mode ?? 'explicit',\n transport: options.transport ?? 'sse',\n basePath: options.basePath ?? '/_mcp',\n version: options.version ?? '0.0.0',\n ...options,\n }\n }\n\n /**\n * Called by the framework each time a module mounts a controller.\n *\n * We don't inspect routes here — we just record the pair and process\n * everything in `beforeStart` once mounting is fully complete. This\n * keeps the scan logic in one place and makes it easier to unit test.\n */\n onRouteMount(controller: Constructor, mountPath: string): void {\n this.mountedControllers.push({ controller, mountPath })\n }\n\n /**\n * Walk collected controllers, read route metadata, and materialize\n * `McpToolDefinition[]`.\n *\n * Runs after every module has mounted but before the HTTP server\n * starts listening, so the MCP server can be initialized in\n * `afterStart` with a complete tool list.\n */\n beforeStart(_ctx: AdapterContext): void {\n for (const { controller, mountPath } of this.mountedControllers) {\n const routes = getClassMeta<RouteDefinition[]>(METADATA.ROUTES, controller, [])\n for (const route of routes) {\n const tool = this.tryBuildTool(controller, mountPath, route)\n if (tool) this.tools.push(tool)\n }\n }\n\n log.debug(\n `MCP adapter discovered ${this.tools.length} tool(s) ` +\n `(mode=${this.options.mode}, transport=${this.options.transport})`,\n )\n }\n\n /**\n * Start the MCP server on the configured transport.\n *\n * - `http` (recommended): mounts a `StreamableHTTPServerTransport` on\n * the existing Express app at `${basePath}/messages`. This is the\n * modern, spec-compliant way to expose MCP over HTTP.\n * - `sse` (deprecated): currently aliases to `http` and emits a warning.\n * The MCP SSE transport class is deprecated upstream in favor of\n * StreamableHTTP, which already supports SSE-style streaming under\n * the hood.\n * - `stdio`: skipped here. The standalone `kick mcp` CLI command\n * instantiates the adapter directly and connects it to a stdio\n * transport so dev logs don't interfere.\n */\n async afterStart(ctx: AdapterContext): Promise<void> {\n // Capture the running server's address so tool dispatch can make\n // internal HTTP requests against the actual port. The framework\n // calls afterStart only once the server is listening, so\n // server.address() returns a real AddressInfo at this point.\n // We capture this regardless of transport mode because dispatch\n // always uses the local HTTP listener — even in stdio mode it's\n // the internal route into the Express pipeline.\n this.serverBaseUrl = this.resolveServerBaseUrl(ctx.server)\n\n const effectiveTransport = this.resolveTransportMode()\n\n if (effectiveTransport === 'stdio') {\n await this.startStdioTransport()\n return\n }\n\n if (effectiveTransport === 'sse') {\n log.warn(\n 'sse transport is deprecated upstream; using StreamableHTTP transport, which supports the same SSE wire format under the hood',\n )\n }\n\n const expressApp = ctx.app as Express | undefined\n if (!expressApp) {\n log.warn('McpAdapter: AdapterContext.app is unavailable, cannot mount HTTP transport')\n return\n }\n\n this.mcpServer = this.buildMcpServer()\n const httpTransport = new StreamableHTTPServerTransport({\n // Stateless mode for v0 — every request gets a fresh session. We can\n // switch to a stateful generator (sessionIdGenerator: randomUUID) once\n // we add session-aware tool dispatch.\n sessionIdGenerator: () => randomUUID(),\n })\n this.transport = httpTransport\n\n await this.mcpServer.connect(httpTransport)\n this.mountHttpRoutes(expressApp, httpTransport)\n\n log.info(\n `McpAdapter ready — ${this.tools.length} tool(s) registered, listening at ${this.options.basePath}/messages`,\n )\n }\n\n /**\n * Decide which transport to actually start.\n *\n * Precedence: an explicit `KICK_MCP_STDIO=1` environment variable\n * always wins, because that's how the `kick mcp` CLI command tells\n * the running process to switch to stdio mode without requiring the\n * user to edit their bootstrap. Otherwise the constructor option is\n * honored as-is.\n */\n private resolveTransportMode(): McpTransport {\n if (process.env.KICK_MCP_STDIO === '1' || process.env.KICK_MCP_STDIO === 'true') {\n return 'stdio'\n }\n return this.options.transport\n }\n\n /**\n * Start the MCP server bound to process stdio.\n *\n * Used by the `kick mcp` CLI: the parent process pipes its stdin/\n * stdout to this adapter so MCP clients (Claude Code, Cursor) can\n * speak the protocol over the wire. Logs MUST go to stderr in this\n * mode — anything written to stdout corrupts the JSON-RPC stream.\n *\n * The HTTP server is still running (the framework called start()\n * before afterStart), but we don't mount the MCP routes on it. Tool\n * dispatch routes through fetch against `serverBaseUrl` exactly the\n * same way it does in HTTP mode, so dispatch behavior is uniform\n * across transports.\n */\n private async startStdioTransport(): Promise<void> {\n this.mcpServer = this.buildMcpServer()\n this.transport = new StdioServerTransport()\n await this.mcpServer.connect(this.transport)\n // Use stderr-friendly log level so we don't break the protocol\n log.info(\n `McpAdapter ready (stdio) — ${this.tools.length} tool(s) registered, dispatching against ${this.serverBaseUrl ?? 'unknown'}`,\n )\n }\n\n /**\n * Tear down the MCP server and any open transports.\n *\n * Called during graceful shutdown. Idempotent — KickJS may invoke\n * `shutdown` more than once under error conditions.\n */\n async shutdown(): Promise<void> {\n try {\n await this.transport?.close()\n } catch (err) {\n log.error(err as Error, 'McpAdapter: failed to close transport')\n }\n try {\n await this.mcpServer?.close()\n } catch (err) {\n log.error(err as Error, 'McpAdapter: failed to close server')\n }\n this.transport = null\n this.mcpServer = null\n this.serverBaseUrl = null\n log.debug('McpAdapter shutdown complete')\n }\n\n /**\n * Return the list of tools discovered during startup.\n *\n * Primary consumers:\n * - the `kick mcp --list` command\n * - unit tests that verify a route was exposed as expected\n */\n getTools(): readonly McpToolDefinition[] {\n return this.tools\n }\n\n // ── Internal helpers ───────────────────────────────────────────────\n\n /**\n * Build a `McpToolDefinition` for a single route, or return `null`\n * if the route should be skipped under the current exposure mode.\n *\n * Scoping rules:\n * - `explicit` (default): only routes with `@McpTool` are exposed\n * - `auto`: every route is exposed, filtered by `include` /\n * `exclude`; a `hidden: true` on `@McpTool` still drops the route\n */\n private tryBuildTool(\n controller: Constructor,\n mountPath: string,\n route: RouteDefinition,\n ): McpToolDefinition | null {\n const meta = getMcpToolMeta(controller.prototype, route.handlerName)\n\n if (this.options.mode === 'explicit' && !meta) return null\n if (meta?.hidden) return null\n\n if (this.options.mode === 'auto') {\n const methodUpper = route.method.toUpperCase() as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'\n if (this.options.include && !this.options.include.includes(methodUpper)) return null\n if (this.options.exclude?.some((prefix) => mountPath.startsWith(prefix))) return null\n }\n\n const description = meta?.description ?? this.deriveDescription(controller, route)\n const name = meta?.name ?? `${controller.name}.${route.handlerName}`\n\n // Prefer the body schema for POST/PUT/PATCH, query schema for GET/DELETE.\n // In `auto` mode the decorator may be absent entirely, in which case we\n // fall back to whatever schema the route decorator declared.\n const candidateSchema = meta?.inputSchema ?? route.validation?.body ?? route.validation?.query\n\n const inputSchema = zodToJsonSchema(candidateSchema) ?? {\n type: 'object',\n properties: {},\n additionalProperties: false,\n }\n\n const outputSchema = meta?.outputSchema ? zodToJsonSchema(meta.outputSchema) : undefined\n\n return {\n name,\n description,\n inputSchema,\n // Keep the original Zod schema alongside the JSON Schema. The MCP\n // SDK accepts Zod directly via `registerTool`, while `inputSchema`\n // (above) is what `getTools()` and inspection surfaces consume.\n zodInputSchema: candidateSchema,\n outputSchema: outputSchema ?? undefined,\n httpMethod: route.method.toUpperCase(),\n mountPath: this.joinMountPath(mountPath, route.path),\n examples: meta?.examples,\n }\n }\n\n /**\n * Derive a default description for routes exposed in `auto` mode\n * without an explicit `@McpTool` decorator. Kept intentionally\n * generic — teams running `auto` should still add `@McpTool` with\n * real descriptions for any tool the model is expected to call\n * reliably.\n */\n private deriveDescription(controller: Constructor, route: RouteDefinition): string {\n return `${route.method.toUpperCase()} handler ${controller.name}.${route.handlerName}`\n }\n\n /**\n * Join a module mount path with the route-level sub-path.\n *\n * Mount path already includes the API prefix + version (e.g.\n * `/api/v1/tasks`); the route-level `path` is relative (e.g. `/:id`).\n * Trailing/leading slashes are normalized so the final URL is stable.\n */\n private joinMountPath(mountPath: string, routePath: string): string {\n const base = mountPath.endsWith('/') ? mountPath.slice(0, -1) : mountPath\n if (!routePath || routePath === '/') return base\n const sub = routePath.startsWith('/') ? routePath : `/${routePath}`\n return `${base}${sub}`\n }\n\n /**\n * Construct the underlying `McpServer` and register every discovered\n * tool against it. The SDK accepts Zod schemas natively, so we pass\n * `zodInputSchema` straight through and skip the JSON Schema form\n * here (the JSON Schema is for inspection / docs).\n *\n * Tool calls dispatch through the Express pipeline via internal HTTP\n * requests against the running server's address (captured in\n * `afterStart`). This preserves middleware, validation, auth, and\n * logging — tool calls behave exactly like external HTTP requests\n * to the same route.\n */\n private buildMcpServer(): McpServer {\n const server = new McpServer({\n name: this.options.name,\n version: this.options.version,\n ...(this.options.description ? { description: this.options.description } : {}),\n })\n\n // The SDK's `registerTool` is heavily overloaded with deep generic\n // inference over Zod input/output shapes. The McpToolDefinition\n // intentionally types `zodInputSchema` as `unknown` to keep our\n // public surface free of SDK internal types, which makes the\n // overload picker unhappy. Cast through `any` once, here, so the\n // call sites stay clean. The SDK validates the schema at register\n // time anyway, so the `any` is bounded to this loop.\n /* eslint-disable @typescript-eslint/no-explicit-any */\n const registerTool = server.registerTool.bind(server) as (\n name: string,\n config: { description: string; inputSchema?: unknown },\n cb: (args: unknown) => any,\n ) => unknown\n /* eslint-enable @typescript-eslint/no-explicit-any */\n\n for (const tool of this.tools) {\n const config: { description: string; inputSchema?: unknown } = {\n description: tool.description,\n }\n if (tool.zodInputSchema) {\n config.inputSchema = tool.zodInputSchema\n }\n registerTool(tool.name, config, async (args) => this.dispatchTool(tool, args))\n }\n\n return server\n }\n\n /**\n * Dispatch a tool call through the Express pipeline.\n *\n * Builds an HTTP request that matches the tool's underlying route\n * (method + path + body or query string from the args) and sends it\n * to the running server's `serverBaseUrl`. The request goes through\n * every middleware the route normally hits — auth, validation,\n * logging, error handling — so tool calls observe exactly the same\n * guarantees as external HTTP clients.\n *\n * Path parameters (e.g. `/:id`) are substituted from `args` before\n * the request fires; matching keys are removed from the body/query\n * to avoid sending them twice.\n *\n * Returns a `CallToolResult` whose `content` contains the response\n * body as text. Non-2xx responses are flagged with `isError: true`\n * so the calling LLM can react.\n */\n private async dispatchTool(\n tool: McpToolDefinition,\n rawArgs: unknown,\n ): Promise<{\n content: Array<{ type: 'text'; text: string }>\n isError?: boolean\n }> {\n if (!this.serverBaseUrl) {\n return {\n isError: true,\n content: [\n {\n type: 'text' as const,\n text: `Cannot dispatch ${tool.name}: HTTP server address not yet captured`,\n },\n ],\n }\n }\n\n const args = (rawArgs ?? {}) as Record<string, unknown>\n const { path, remainingArgs } = this.substitutePathParams(tool.mountPath, args)\n const method = tool.httpMethod.toUpperCase()\n const hasBody = method === 'POST' || method === 'PUT' || method === 'PATCH'\n\n let url = `${this.serverBaseUrl}${path}`\n const init: RequestInit = {\n method,\n headers: {\n accept: 'application/json',\n 'x-mcp-tool': tool.name,\n },\n }\n\n if (hasBody) {\n ;(init.headers as Record<string, string>)['content-type'] = 'application/json'\n init.body = JSON.stringify(remainingArgs)\n } else if (Object.keys(remainingArgs).length > 0) {\n // GET / DELETE: serialize args as a query string\n const qs = new URLSearchParams()\n for (const [key, value] of Object.entries(remainingArgs)) {\n if (value === undefined || value === null) continue\n qs.append(key, typeof value === 'string' ? value : JSON.stringify(value))\n }\n const sep = url.includes('?') ? '&' : '?'\n url = `${url}${sep}${qs.toString()}`\n }\n\n try {\n const res = await fetch(url, init)\n const text = await res.text()\n return {\n isError: res.status >= 400,\n content: [\n {\n type: 'text' as const,\n text: text || `(${res.status} ${res.statusText})`,\n },\n ],\n }\n } catch (err) {\n const message = err instanceof Error ? err.message : String(err)\n log.error(err as Error, `McpAdapter: dispatch failed for ${tool.name}`)\n return {\n isError: true,\n content: [\n {\n type: 'text' as const,\n text: `Tool dispatch error: ${message}`,\n },\n ],\n }\n }\n }\n\n /**\n * Substitute Express-style path parameters (`:id`) in `mountPath`\n * with values from `args`. Returns the resolved path plus the args\n * that were NOT consumed by parameters, so they can be sent as the\n * request body or query string.\n *\n * If a `:param` is referenced in the path but missing from args,\n * the placeholder is left in place — the request will hit a 404 from\n * the underlying route, which is reported back as an MCP error.\n */\n private substitutePathParams(\n mountPath: string,\n args: Record<string, unknown>,\n ): { path: string; remainingArgs: Record<string, unknown> } {\n const remaining: Record<string, unknown> = { ...args }\n const path = mountPath.replace(/:([a-zA-Z_][a-zA-Z0-9_]*)/g, (_match, param: string) => {\n if (param in remaining) {\n const value = remaining[param]\n delete remaining[param]\n return encodeURIComponent(String(value))\n }\n return `:${param}`\n })\n return { path, remainingArgs: remaining }\n }\n\n /**\n * Resolve the running server's base URL from a Node `http.Server`\n * instance. Returns null if the server isn't listening or its\n * address can't be determined (e.g. when the adapter is mounted\n * standalone for testing).\n *\n * IPv6 addresses are wrapped in brackets per RFC 3986. The hostname\n * `0.0.0.0` (Linux default) is rewritten to `127.0.0.1` because the\n * former is not a valid request target on all platforms.\n */\n private resolveServerBaseUrl(server: AdapterContext['server']): string | null {\n if (!server) return null\n const address = server.address()\n if (!address || typeof address === 'string') return null\n let host = address.address\n if (host === '::' || host === '0.0.0.0' || host === '') host = '127.0.0.1'\n if (host.includes(':') && !host.startsWith('[')) host = `[${host}]`\n return `http://${host}:${address.port}`\n }\n\n /**\n * Mount the StreamableHTTP transport endpoints on the existing\n * Express app. The transport handles three HTTP verbs at a single\n * URL:\n * - POST: client → server messages (initialize, tool calls, etc.)\n * - GET: server → client SSE stream for notifications\n * - DELETE: client tells the server to terminate a session\n *\n * We mount all three on `${basePath}/messages` so a single URL is\n * the entire MCP surface area.\n */\n private mountHttpRoutes(app: Express, transport: StreamableHTTPServerTransport): void {\n const path = `${this.options.basePath}/messages`\n\n /* eslint-disable @typescript-eslint/no-explicit-any */\n const handleRequest = async (req: any, res: any): Promise<void> => {\n try {\n await transport.handleRequest(req, res, req.body)\n } catch (err) {\n log.error(err as Error, `McpAdapter: error handling ${req.method} ${path}`)\n if (!res.headersSent) {\n res.status(500).json({ error: 'MCP transport error' })\n }\n }\n }\n /* eslint-enable @typescript-eslint/no-explicit-any */\n\n app.post(path, handleRequest)\n app.get(path, handleRequest)\n app.delete(path, handleRequest)\n }\n}\n"],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,107 @@
1
+ {
2
+ "name": "@forinda/kickjs-mcp",
3
+ "version": "2.3.0",
4
+ "description": "Model Context Protocol server adapter for KickJS — expose @Controller endpoints as MCP tools",
5
+ "keywords": [
6
+ "kickjs",
7
+ "nodejs",
8
+ "typescript",
9
+ "express",
10
+ "decorators",
11
+ "dependency-injection",
12
+ "backend",
13
+ "api",
14
+ "framework",
15
+ "mcp",
16
+ "model-context-protocol",
17
+ "ai",
18
+ "claude",
19
+ "anthropic",
20
+ "tool-calling",
21
+ "agent",
22
+ "llm",
23
+ "@forinda/kickjs",
24
+ "@forinda/kickjs-cli",
25
+ "@forinda/kickjs-swagger"
26
+ ],
27
+ "type": "module",
28
+ "main": "dist/index.mjs",
29
+ "types": "dist/index.d.mts",
30
+ "exports": {
31
+ ".": {
32
+ "import": "./dist/index.mjs",
33
+ "types": "./dist/index.d.mts"
34
+ }
35
+ },
36
+ "files": [
37
+ "dist"
38
+ ],
39
+ "wireit": {
40
+ "build": {
41
+ "command": "tsdown",
42
+ "files": [
43
+ "src/**/*.ts",
44
+ "tsdown.config.ts",
45
+ "tsconfig.json",
46
+ "package.json"
47
+ ],
48
+ "output": [
49
+ "dist/**"
50
+ ],
51
+ "dependencies": [
52
+ "../kickjs:build"
53
+ ]
54
+ }
55
+ },
56
+ "dependencies": {
57
+ "reflect-metadata": "^0.2.2",
58
+ "@forinda/kickjs": "2.3.0"
59
+ },
60
+ "peerDependencies": {
61
+ "@modelcontextprotocol/sdk": "^1.0.0",
62
+ "zod": "^4.3.6"
63
+ },
64
+ "peerDependenciesMeta": {
65
+ "@modelcontextprotocol/sdk": {
66
+ "optional": false
67
+ }
68
+ },
69
+ "devDependencies": {
70
+ "@modelcontextprotocol/sdk": "^1.0.0",
71
+ "@swc/core": "^1.15.21",
72
+ "@types/express": "^5.0.6",
73
+ "@types/node": "^25.0.0",
74
+ "@types/supertest": "^7.2.0",
75
+ "express": "^5.1.0",
76
+ "supertest": "^7.2.2",
77
+ "typescript": "^5.9.2",
78
+ "unplugin-swc": "^1.5.9",
79
+ "vitest": "^4.1.2",
80
+ "zod": "^4.3.6"
81
+ },
82
+ "publishConfig": {
83
+ "access": "public"
84
+ },
85
+ "license": "MIT",
86
+ "author": "Felix Orinda",
87
+ "engines": {
88
+ "node": ">=20.0"
89
+ },
90
+ "homepage": "https://forinda.github.io/kick-js/",
91
+ "repository": {
92
+ "type": "git",
93
+ "url": "https://github.com/forinda/kick-js.git",
94
+ "directory": "packages/mcp"
95
+ },
96
+ "bugs": {
97
+ "url": "https://github.com/forinda/kick-js/issues"
98
+ },
99
+ "scripts": {
100
+ "build": "wireit",
101
+ "dev": "tsdown --watch",
102
+ "test": "vitest run --passWithNoTests",
103
+ "test:watch": "vitest",
104
+ "typecheck": "tsc --noEmit",
105
+ "clean": "rm -rf dist .wireit"
106
+ }
107
+ }