@ganintegrity/mcp 1.0.0 → 1.1.1

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.
Files changed (80) hide show
  1. package/README.md +191 -84
  2. package/dist/{als.d.ts → core/als.d.ts} +11 -4
  3. package/dist/core/als.d.ts.map +1 -0
  4. package/dist/core/als.js.map +1 -0
  5. package/dist/{auth → core/auth}/auth.types.d.ts +6 -0
  6. package/dist/core/auth/auth.types.d.ts.map +1 -0
  7. package/dist/{auth → core/auth}/auth.types.js.map +1 -1
  8. package/dist/core/auth/index.d.ts +34 -0
  9. package/dist/core/auth/index.d.ts.map +1 -0
  10. package/dist/core/auth/index.js +57 -0
  11. package/dist/core/auth/index.js.map +1 -0
  12. package/dist/core/define.d.ts +80 -0
  13. package/dist/core/define.d.ts.map +1 -0
  14. package/dist/core/define.js +52 -0
  15. package/dist/core/define.js.map +1 -0
  16. package/dist/core/dispatch.d.ts +35 -0
  17. package/dist/core/dispatch.d.ts.map +1 -0
  18. package/dist/core/dispatch.js +57 -0
  19. package/dist/core/dispatch.js.map +1 -0
  20. package/dist/{errors → core/errors}/errors.types.d.ts +21 -0
  21. package/dist/core/errors/errors.types.d.ts.map +1 -0
  22. package/dist/core/errors/errors.types.js.map +1 -0
  23. package/dist/{errors → core/errors}/index.d.ts +4 -0
  24. package/dist/core/errors/index.d.ts.map +1 -0
  25. package/dist/{errors → core/errors}/index.js +4 -0
  26. package/dist/core/errors/index.js.map +1 -0
  27. package/dist/core/test-helpers.d.ts +20 -0
  28. package/dist/core/test-helpers.d.ts.map +1 -0
  29. package/dist/core/test-helpers.js +38 -0
  30. package/dist/core/test-helpers.js.map +1 -0
  31. package/dist/core/tool/index.d.ts +19 -0
  32. package/dist/core/tool/index.d.ts.map +1 -0
  33. package/dist/{tool → core/tool}/index.js +28 -25
  34. package/dist/core/tool/index.js.map +1 -0
  35. package/dist/{tool → core/tool}/tool.types.d.ts +38 -2
  36. package/dist/core/tool/tool.types.d.ts.map +1 -0
  37. package/dist/{tool → core/tool}/tool.types.js.map +1 -1
  38. package/dist/{auth/index.d.ts → express/auth.d.ts} +10 -9
  39. package/dist/express/auth.d.ts.map +1 -0
  40. package/dist/express/auth.js +49 -0
  41. package/dist/express/auth.js.map +1 -0
  42. package/dist/express/express.types.d.ts +37 -0
  43. package/dist/express/express.types.d.ts.map +1 -0
  44. package/dist/express/express.types.js +2 -0
  45. package/dist/express/express.types.js.map +1 -0
  46. package/dist/express/index.d.ts +41 -0
  47. package/dist/express/index.d.ts.map +1 -0
  48. package/dist/express/index.js +59 -0
  49. package/dist/express/index.js.map +1 -0
  50. package/dist/index.d.ts +21 -18
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +10 -4
  53. package/dist/index.js.map +1 -1
  54. package/package.json +8 -1
  55. package/dist/als.d.ts.map +0 -1
  56. package/dist/als.js.map +0 -1
  57. package/dist/auth/auth.types.d.ts.map +0 -1
  58. package/dist/auth/index.d.ts.map +0 -1
  59. package/dist/auth/index.js +0 -74
  60. package/dist/auth/index.js.map +0 -1
  61. package/dist/errors/errors.types.d.ts.map +0 -1
  62. package/dist/errors/errors.types.js.map +0 -1
  63. package/dist/errors/index.d.ts.map +0 -1
  64. package/dist/errors/index.js.map +0 -1
  65. package/dist/server/index.d.ts +0 -18
  66. package/dist/server/index.d.ts.map +0 -1
  67. package/dist/server/index.js +0 -130
  68. package/dist/server/index.js.map +0 -1
  69. package/dist/server/server.types.d.ts +0 -78
  70. package/dist/server/server.types.d.ts.map +0 -1
  71. package/dist/server/server.types.js +0 -2
  72. package/dist/server/server.types.js.map +0 -1
  73. package/dist/tool/index.d.ts +0 -26
  74. package/dist/tool/index.d.ts.map +0 -1
  75. package/dist/tool/index.js.map +0 -1
  76. package/dist/tool/tool.types.d.ts.map +0 -1
  77. /package/dist/{als.js → core/als.js} +0 -0
  78. /package/dist/{auth → core/auth}/auth.types.js +0 -0
  79. /package/dist/{errors → core/errors}/errors.types.js +0 -0
  80. /package/dist/{tool → core/tool}/tool.types.js +0 -0
package/README.md CHANGED
@@ -2,8 +2,6 @@
2
2
 
3
3
  A small library for mounting a [Model Context Protocol](https://modelcontextprotocol.io) server onto an Express app inside a Gan Integrity service. It owns the MCP plumbing — Streamable-HTTP transport wiring, per-request scoping, tool-result envelopes — and **inverts the dependencies** that vary per service: auth, logging, postgan construction, and the application's own error types are all injected by the caller.
4
4
 
5
- If you've worked on a `src/mcp/scaffold/` folder before, this is the library extraction of that pattern.
6
-
7
5
  ---
8
6
 
9
7
  ## Why this library exists
@@ -26,15 +24,15 @@ This library packages (1)–(4) and asks the caller to plug in the parts that ge
26
24
 
27
25
  A two-minute mental model before you read the API:
28
26
 
29
- ### The recording shim
27
+ ### Define-once, materialize-per-request
30
28
 
31
- `createMcpServer()` returns a `server` object that _looks_ like an `McpServer` but only implements `registerTool` — it records every registration in an in-memory list. On each HTTP request, `mount()` instantiates a brand-new `McpServer`, replays the recorded registrations onto it, hands it a fresh transport, and tears both down when the response finishes. This is how we satisfy the SDK's single-use transport contract without re-registering tools on every call site.
29
+ `defineMcpServer({ tools })` runs your `tools(register)` callback once and captures each `register.tool(spec)` invocation onto an `McpDefinition`. On each HTTP request, the framework adapter calls `materializeSdkServer(mcp)` to build a fresh SDK `McpServer`, replays the captured registrations onto it, hands it a fresh transport, and tears both down when the response finishes. This is how we satisfy the SDK's single-use transport contract without re-registering tools on every call site.
32
30
 
33
- You register tools once at boot time. The library handles the per-request lifecycle.
31
+ You write tool registrations once. The library handles the per-request lifecycle.
34
32
 
35
33
  ### The ALS scope
36
34
 
37
- Inside `mount()`, every tool dispatch runs inside an `AsyncLocalStorage.run(...)` scope holding a `RequestStore` (user, postgan, sessionId, logger). The `tool()` helper reads from this store to build a `ToolContext` for your handler. Your handler signature is `(args, ctx) => Promise<Output>` — no Express objects leak in, no DI container needed.
35
+ Every tool dispatch runs inside an `AsyncLocalStorage.run(...)` scope holding a `RequestStore` (user, postgan, sessionId, logger). The wrapper that `register.tool()` builds reads from this store to assemble a `ToolContext` for your handler. Your handler signature is `(args, ctx) => Promise<Output>` — no Express objects leak in, no DI container needed.
38
36
 
39
37
  ### Per-tool transactions
40
38
 
@@ -71,7 +69,8 @@ The library does not bundle any of these — your service is expected to already
71
69
  ```ts
72
70
  // src/mcp/bootstrap.ts
73
71
  import express from "express";
74
- import { createMcpServer, tool, type McpErrorMapper } from "@ganintegrity/mcp";
72
+ import { defineMcpServer, type McpErrorMapper } from "@ganintegrity/mcp";
73
+ import { createMcpRouter } from "@ganintegrity/mcp/express";
75
74
  import { z } from "zod";
76
75
 
77
76
  import { logger } from "../logger.ts";
@@ -93,80 +92,178 @@ const errorMapper: McpErrorMapper = (err) => {
93
92
  return null;
94
93
  };
95
94
 
96
- const { server, mount } = createMcpServer({
95
+ // Register tools at boot, inside the `tools` callback. The callback runs
96
+ // once at define time; the registrations are replayed onto a fresh SDK
97
+ // `McpServer` per HTTP request.
98
+ const mcp = defineMcpServer({
97
99
  name: "tprm-mcp",
98
100
  version: "1.0.0",
101
+ errorMapper,
102
+ tools(register) {
103
+ register.tool({
104
+ name: "summarise_entity",
105
+ description: "Produce a short summary of an entity by uuid.",
106
+ inputSchema: z.object({ uuid: z.string().uuid() }),
107
+ handler: async ({ uuid }, ctx) => {
108
+ const entity = await ctx.postgan.entity.fetch({ uuid });
109
+ return { summary: entity.name, fetchedBy: ctx.user.id };
110
+ },
111
+ });
112
+ },
113
+ });
114
+
115
+ // Mount onto an Express app.
116
+ const mcpRouter = createMcpRouter(mcp, {
99
117
  logger,
100
118
  setupPostgan: setupPostgan(),
101
119
  tokenToUser: async (token) => {
102
120
  const decoded = await decipherToken(token);
103
121
  return identity.construct({ ...decoded, id: decoded._id });
104
122
  },
123
+ });
124
+ app.use("/mcp", mcpRouter);
125
+ ```
126
+
127
+ That's it. The MCP server is reachable at `POST /mcp/`, authenticates via `Authorization: Bearer <token>`, and executes tools inside a postgan transaction with full ALS context.
128
+
129
+ ---
130
+
131
+ ## Framework adapters
132
+
133
+ The library is split into a framework-neutral core and per-framework adapters. Consumers import from a subpath that matches their HTTP framework.
134
+
135
+ | Import | Status | Use |
136
+ | --------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------- |
137
+ | `@ganintegrity/mcp` | Implemented | Framework-neutral surface: `defineMcpServer`, `materializeSdkServer`, error envelope plumbing, every shared type. |
138
+ | `@ganintegrity/mcp/express` | Implemented | Express ^5 adapter: `createMcpRouter`, `mcpAuth`, `CreateMcpRouterOptions`. |
139
+ | `@ganintegrity/mcp/koa` | Not yet built | Koa ^2 adapter; intended API documented below. Same names against Koa types. |
140
+
141
+ A bootstrap file imports from both — framework-neutral helpers from the root, the adapter factory from `/express`. A tool definition file imports from the root only; it stays adapter-agnostic.
142
+
143
+ Importing from `/express` (or `/koa`, when implemented) loads the framework's ambient type augmentation (e.g. `Express.Request` gains `mcpSessionId` / `auth`). The root entry has no such side effects — services that don't run Express won't see Express types in their globals.
144
+
145
+ The shared core lives under `src/core/` and contains:
146
+
147
+ - **`defineMcpServer({ name, version, errorMapper, tools })`** — the public entry point. Captures tool registrations into an `McpDefinition` once at define time.
148
+ - **`materializeSdkServer(mcp): McpServer`** — builds a fresh SDK `McpServer` and replays the captured registrations onto it. Adapters call this per HTTP request (the SDK's stateless transport is single-use). Exposed for tests + advanced consumers writing custom adapters.
149
+ - **`resolveAuth(headers, options): AuthResult`** — extracts the bearer token + session id from a header bag, calls `tokenToUser`, returns a discriminated success/failure union.
150
+ - **`dispatchMcpRequest(mcp, logger, scope, invokeTransport, onTransportError)`** — owns the per-request lifecycle: builds the `RequestStore`, materializes the SDK server, runs the framework-supplied transport invocation inside `requestStore.run(...)`, tears everything down on `finally`.
151
+ - The error envelope plumbing (`toCallToolError`, `INTERNAL_ERROR`) and the postgan-transaction wrapper.
152
+
153
+ Each adapter is a thin layer that bridges its native request/response shape to these neutral helpers.
154
+
155
+ ### Intended Koa API
156
+
157
+ When the Koa adapter is built, it mirrors the Express one:
158
+
159
+ ```ts
160
+ // src/mcp/bootstrap.ts
161
+ import Koa from "koa";
162
+ import Router from "@koa/router";
163
+ import bodyParser from "@koa/bodyparser";
164
+
165
+ import { defineMcpServer, type McpErrorMapper } from "@ganintegrity/mcp";
166
+ import { createMcpRouter } from "@ganintegrity/mcp/koa";
167
+
168
+ const mcp = defineMcpServer({
169
+ name: "tprm-mcp",
170
+ version: "1.0.0",
105
171
  errorMapper,
172
+ tools(register) {
173
+ register.tool({
174
+ /* identical to express — same ToolContext, same handler signature */
175
+ });
176
+ },
106
177
  });
107
178
 
108
- // Register tools at boot.
109
- tool(server, {
110
- name: "summarise_entity",
111
- description: "Produce a short summary of an entity by uuid.",
112
- inputSchema: z.object({ uuid: z.string().uuid() }),
113
- handler: async ({ uuid }, ctx) => {
114
- const entity = await ctx.postgan.entity.fetch({ uuid });
115
- return { summary: entity.name, fetchedBy: ctx.user.id };
179
+ const mcpRouter = createMcpRouter(mcp, {
180
+ logger,
181
+ setupPostgan: setupPostganKoa(), // Koa.Middleware that sets ctx.state.postgan
182
+ tokenToUser: async (token) => {
183
+ /* same shape as express */
116
184
  },
117
185
  });
118
186
 
119
- // Mount onto an Express router.
120
- const router = express.Router();
121
- await mount(router);
122
- app.use("/mcp", router);
187
+ const app = new Koa();
188
+ app.use(bodyParser()); // caller-provided; the library does not bundle one
189
+ app.use(mcpRouter.routes()).use(mcpRouter.allowedMethods());
123
190
  ```
124
191
 
125
- That's it. The MCP server is reachable at `POST /mcp/`, will authenticate via `Authorization: Bearer <token>` (or `X-Access-Token`), and will execute tools inside a postgan transaction with full ALS context.
192
+ Differences from the Express adapter the implementation needs to handle:
193
+
194
+ - **`setupPostgan: Koa.Middleware`** — sets `ctx.state.postgan` instead of `req.postgan`. The auth middleware sets `ctx.state.user` (and `ctx.state.mcpSessionId` if present).
195
+ - **`mcpAuth(options): Koa.Middleware`** — parallel to the Express middleware. Same options shape (`tokenToUser`, `logger`); internally calls `resolveAuth(ctx.headers, options)` and either responds 401 (`ctx.status` / `ctx.body`) or writes onto `ctx.state` and calls `next()`.
196
+ - **`createMcpRouter(mcp, options)`** — returns a fresh `@koa/router` Router with the JSON-RPC route attached; the consumer mounts it via `app.use(router.routes()).use(router.allowedMethods())`.
197
+ - **Body parser is the consumer's responsibility.** Koa has no built-in JSON parser. The Express adapter mounts `express.json()` automatically; the Koa adapter expects the caller to mount `bodyParser()` (or equivalent) on the Koa app upstream of the MCP router.
198
+ - **`handleMcpRequest`** — the dispatcher's `invokeTransport` callback becomes:
199
+ ```ts
200
+ (transport) => {
201
+ ctx.respond = false; // opt out of Koa's response handling
202
+ return transport.handleRequest(ctx.req, ctx.res, ctx.request.body);
203
+ };
204
+ ```
205
+ Koa's `ctx.req` / `ctx.res` are the underlying Node `IncomingMessage` / `ServerResponse`, which is what the MCP SDK transport expects.
206
+ - **`onTransportError`** — `() => { if (!ctx.headerSent) { ctx.status = 500; ctx.body = { error: "Internal MCP transport error" }; } }`.
207
+ - **No global type augmentation.** The Express adapter augments `Express.Request` with `mcpSessionId` / `auth`; Koa uses typed `ctx.state<T>`. The Koa adapter exports a `KoaState` interface consumers can compose with their own state typing.
208
+
209
+ Tool handlers see the same `ToolContext` regardless of adapter — the framework-specific bits stop at the auth + dispatch boundary.
210
+
211
+ ### Planned Koa peer dependencies
212
+
213
+ When the Koa adapter ships, these peers will be added (only required if you actually import the `/koa` subpath):
214
+
215
+ | Peer | Range | Why |
216
+ | ------------- | ----- | ----------------------------------- |
217
+ | `koa` | `^2` | adapter is built against it. |
218
+ | `@koa/router` | `^13` | `createMcpRouter` returns a Router. |
219
+
220
+ `@koa/bodyparser` is **not** a peer — the consumer chooses and mounts a body parser on their app.
126
221
 
127
222
  ---
128
223
 
129
224
  ## API reference
130
225
 
131
- ### `createMcpServer(options)`
226
+ > Each export below is annotated with the subpath it lives on — `@ganintegrity/mcp` (framework-neutral) or `@ganintegrity/mcp/express` (Express adapter). See [Framework adapters](#framework-adapters) for the full split.
132
227
 
133
- Builds an MCP server and returns a `{ server, mount }` pair.
228
+ ### `defineMcpServer(options)` `@ganintegrity/mcp`
134
229
 
135
- **Options:**
230
+ Captures an MCP server definition. The `tools` callback runs **once at define time**; the `register.tool(spec)` invocations inside it are recorded and replayed onto a fresh SDK `McpServer` per HTTP request.
136
231
 
137
- | Field | Type | Description |
138
- | -------------- | -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
139
- | `name` | `string` | MCP server name advertised to clients. |
140
- | `version` | `string` | MCP server version advertised to clients. |
141
- | `logger` | `pino.Logger` | Service logger. The library calls `logger.child({ component: "mcp" })` internally. |
142
- | `tokenToUser` | `(token: string) => Promise<AuthUser>` | Resolve a bearer token to a user. The library is neutral about how tokens are verified — plug in your service's existing JWT/cipher/identity pipeline. |
143
- | `setupPostgan` | `RequestHandler` | Express middleware that attaches a `Postgan` instance to `req.postgan`. The library mounts this between auth and the JSON-RPC dispatcher. |
144
- | `errorMapper?` | `McpErrorMapper` | Translate a thrown error into an MCP envelope. Return `null` for the redacted `INTERNAL_ERROR` fallback. Without a mapper, every thrown error becomes `INTERNAL_ERROR` with the message redacted. |
232
+ **Options:**
145
233
 
146
- **Returns:**
234
+ | Field | Type | Description |
235
+ | -------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
236
+ | `name` | `string` | MCP server name advertised to clients. |
237
+ | `version` | `string` | MCP server version advertised to clients. |
238
+ | `tools` | `(register: ToolRegister) => void` | Registration callback. Call `register.tool(spec)` once per tool. Anything beyond registration runs once at define time, not per request. |
239
+ | `errorMapper?` | `McpErrorMapper` | Translate a thrown error into an MCP envelope. Return `null` for the redacted `INTERNAL_ERROR` fallback. Without a mapper, every thrown error becomes `INTERNAL_ERROR`. |
147
240
 
148
- | Field | Type | Description |
149
- | -------- | ----------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
150
- | `server` | `McpServer` (recording shim) | Pass to `tool()` to register tools. Only `registerTool` is exposed; other `McpServer` methods are not implemented. |
151
- | `mount` | `(target: Router) => Promise<void>` | Attaches the MCP route handlers to an Express router. Caller decides the mount path. |
241
+ **Returns:** an opaque `McpDefinition` you hand to a per-framework adapter (`createMcpRouter`, etc.). Don't inspect or mutate it — adapters and `materializeSdkServer` are the only consumers.
152
242
 
153
- ### `tool(server, spec)`
243
+ ### `register.tool(spec)` — `@ganintegrity/mcp`
154
244
 
155
- Register a tool. The handler runs inside an ALS scope with a fresh postgan transaction.
245
+ The method on the `register` argument inside `defineMcpServer`'s `tools` callback. Wraps your handler with ALS scope, an auto-committed postgan transaction, and the error envelope pipeline before recording it onto the definition.
156
246
 
157
247
  ```ts
158
- tool(server, {
159
- name: "tool_name",
160
- description: "What this tool does",
161
- inputSchema: z.object({
162
- /* ... */
163
- }),
164
- annotations: { readOnlyHint: true }, // optional
165
- handler: async (args, ctx) => {
166
- // ctx.user, ctx.postgan, ctx.transaction, ctx.sessionId, ctx.logger
167
- return {
168
- /* plain object — surfaced as both text and structuredContent */
169
- };
248
+ const mcp = defineMcpServer({
249
+ name: "tprm-mcp",
250
+ version: "1.0.0",
251
+ errorMapper,
252
+ tools(register) {
253
+ register.tool({
254
+ name: "tool_name",
255
+ description: "What this tool does",
256
+ inputSchema: z.object({
257
+ /* ... */
258
+ }),
259
+ annotations: { readOnlyHint: true }, // optional
260
+ handler: async (args, ctx) => {
261
+ // ctx.user, ctx.postgan, ctx.transaction, ctx.sessionId, ctx.logger
262
+ return {
263
+ /* plain object — surfaced as both text and structuredContent */
264
+ };
265
+ },
266
+ });
170
267
  },
171
268
  });
172
269
  ```
@@ -191,9 +288,34 @@ tool(server, {
191
288
  | `idempotentHint` | Calling twice with same args is safe |
192
289
  | `openWorldHint` | Tool reaches out to external systems |
193
290
 
194
- ### `mcpAuth(options)`
291
+ ### `createMcpRouter(mcp, options)` — `@ganintegrity/mcp/express`
292
+
293
+ Build an Express `Router` that serves the supplied `McpDefinition`. Mount on whatever path the caller likes:
294
+
295
+ ```ts
296
+ const mcpRouter = createMcpRouter(mcp, {
297
+ logger,
298
+ setupPostgan: setupPostgan(),
299
+ tokenToUser: async (token) => {
300
+ /* ... */
301
+ },
302
+ });
303
+ app.use("/mcp", mcpRouter);
304
+ ```
305
+
306
+ **Options:**
307
+
308
+ | Field | Type | Description |
309
+ | -------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
310
+ | `logger` | `pino.Logger` | Service logger. The library calls `logger.child({ component: "mcp" })` internally and further childs per request and per tool call. |
311
+ | `tokenToUser` | `(token: string) => Promise<AuthUser>` | Resolve a bearer token to a user. Reject by throwing — the router responds 401. |
312
+ | `setupPostgan` | `RequestHandler` | Express middleware that attaches a `Postgan` instance to `req.postgan`. The router mounts it between auth and the JSON-RPC dispatcher. |
313
+
314
+ **Returns:** an `express.Router` with `express.json()` → `mcpAuth` → `setupPostgan` → JSON-RPC dispatch composed onto it. Each request runs through the chain once.
195
315
 
196
- The auth middleware that `createMcpServer` mounts internally. Exported in case you need to compose it differently.
316
+ ### `mcpAuth(options)` `@ganintegrity/mcp/express`
317
+
318
+ The auth middleware that `createMcpRouter` mounts internally. Exported in case you need to compose it differently.
197
319
 
198
320
  ```ts
199
321
  mcpAuth({
@@ -206,7 +328,7 @@ mcpAuth({
206
328
 
207
329
  Sets `req.user`, `req.headers.company`, `req.auth` (SDK-shaped `AuthInfo`), and optionally `req.mcpSessionId` (from `X-Session-Id` header).
208
330
 
209
- ### Errors
331
+ ### Errors — `@ganintegrity/mcp`
210
332
 
211
333
  The library deliberately does **not** define an error class. Your service throws whatever it already throws (an `AppError`, a `ZodError`, anything), and you supply an `McpErrorMapper` callback to translate those throwables into MCP envelopes at the tool boundary.
212
334
 
@@ -231,9 +353,9 @@ import {
231
353
  ```
232
354
  - **`McpErrorMapper`** — `(err: unknown) => McpToolError | null`. Return `null` to fall through to a redacted `INTERNAL_ERROR` envelope (the original message is **not** leaked to the agent).
233
355
  - **`INTERNAL_ERROR`** — string constant, the only code the library itself emits.
234
- - **`toCallToolError(mapped, originalErr, meta)`** — used internally by `tool()`. Exported in case you build your own tool wrappers.
356
+ - **`toCallToolError(mapped, originalErr, meta)`** — used internally by `register.tool()`. Exported in case you build your own tool wrappers.
235
357
 
236
- The `tool()` helper invokes the mapper and `toCallToolError` automatically when a handler throws — you typically don't call them directly.
358
+ The `register.tool()` wrapper invokes the mapper and `toCallToolError` automatically when a handler throws — you typically don't call them directly.
237
359
 
238
360
  ---
239
361
 
@@ -254,7 +376,7 @@ declare global {
254
376
  }
255
377
  ```
256
378
 
257
- This ships in `dist/index.d.ts` — once you import anything from `@ganintegrity/mcp`, both fields are available on `Request` throughout your project. The library does **not** declare `req.user` or `req.postgan` — those remain your service's concern (typically in your existing `types/express.d.ts`).
379
+ This ships in `dist/express/index.d.ts` — once you import anything from `@ganintegrity/mcp/express`, both fields are available on `Request` throughout your project. The framework-neutral root entry (`@ganintegrity/mcp`) has no Express side-effects, so services that don't run Express won't see Express types in their globals. The library does **not** declare `req.user` or `req.postgan` — those remain your service's concern (typically in your existing `types/express.d.ts`).
258
380
 
259
381
  ### The `AuthUser` contract
260
382
 
@@ -316,13 +438,15 @@ Mapping rules to keep in mind:
316
438
 
317
439
  ## Architecture deep dive
318
440
 
319
- ### Why a recording shim?
441
+ ### Why `defineMcpServer` + `materializeSdkServer` instead of `new McpServer()`?
320
442
 
321
443
  The natural shape would be: `const server = new McpServer(...); server.registerTool(...); app.post("/mcp", (req, res) => transport.handleRequest(req, res, req.body))`. This breaks because `StreamableHTTPServerTransport` in stateless mode is single-use — once it has handled one request, it cannot be reused. The MCP SDK enforces this internally.
322
444
 
323
445
  The fix is to instantiate a transport per request. But then the `McpServer` would also need to be per-request (the SDK couples the two via `connect()`), which means re-registering every tool on every request.
324
446
 
325
- The recording shim solves this: tools are registered once into a list, and `mount()` replays the list onto a fresh `McpServer` per request. The replay is cheap — `registerTool` just stores function references, and the McpServer constructor is similarly lightweight. End-to-end latency is dominated by the actual tool work.
447
+ The library splits the two concerns: `defineMcpServer` captures registrations once into an `McpDefinition`; `materializeSdkServer(mcp)` builds a fresh SDK server with the same tools per request. The replay is cheap — `registerTool` just stores function references, and the `McpServer` constructor is similarly lightweight. End-to-end latency is dominated by the actual tool work.
448
+
449
+ The earlier shape used a "recording shim" — a fake `McpServer` whose only working method was `registerTool`. That dishonesty (it looked like an `McpServer`, but `.close()` and friends would have failed) is what `defineMcpServer` replaces.
326
450
 
327
451
  ### Why AsyncLocalStorage instead of passing context?
328
452
 
@@ -331,7 +455,7 @@ Tools could receive the `Request`/`Response` directly, and pull `user`/`postgan`
331
455
  1. Couples every tool to Express. If the MCP server ever needs to be invoked over a different transport (a worker, a CLI, a test), every tool breaks.
332
456
  2. Forces every helper called by a tool to also receive the context, polluting helper signatures throughout the service.
333
457
 
334
- ALS lets tool helpers reach context anywhere down the call stack without parameter threading, while keeping the tool-handler signature `(args, ctx)` clean and transport-agnostic. The `requestStore.run(...)` boundary in `mount()` is the only place ALS knows about Express.
458
+ ALS lets tool helpers reach context anywhere down the call stack without parameter threading, while keeping the tool-handler signature `(args, ctx)` clean and transport-agnostic. The `requestStore.run(...)` boundary in `dispatchMcpRequest` is the only place ALS knows about Express.
335
459
 
336
460
  ### Why per-tool transactions?
337
461
 
@@ -339,37 +463,20 @@ A single MCP request can dispatch multiple tool calls. Wrapping the entire reque
339
463
 
340
464
  The cost is more transaction begin/commits, which on a healthy postgres is negligible.
341
465
 
342
- ### Why is `tool()` separate from the `McpServer` returned by `createMcpServer`?
466
+ ### Why `register.tool(spec)` instead of `server.registerTool(...)`?
343
467
 
344
- `tool()` is a thin wrapper that:
468
+ `register.tool(spec)` is a thin wrapper around the SDK's `registerTool` that:
345
469
 
346
470
  1. Reads the ALS store to build a `ToolContext`.
347
471
  2. Opens a transaction, runs your handler, commits or rolls back.
348
472
  3. Maps thrown errors to `CallToolResult` envelopes.
349
473
 
350
- You could call `server.registerTool(...)` directly and skip all of this — useful if you want raw access to the SDK's request/response shape. But you'd lose ALS, transactions, and the error mapping. `tool()` is what makes the rest of the library useful; `server.registerTool` is the escape hatch.
474
+ You could call `materializeSdkServer(mcp).registerTool(...)` directly and skip all of this — useful if you want raw access to the SDK's request/response shape. But you'd lose ALS, transactions, and the error mapping. `register.tool()` is what makes the rest of the library useful; the SDK API is the escape hatch.
351
475
 
352
476
  ---
353
477
 
354
- ## Development
355
-
356
- ```bash
357
- pnpm install # install deps
358
- pnpm build # tsc → dist/
359
- pnpm typecheck # tsc --noEmit
360
- pnpm test # vitest run
361
- pnpm lint # eslint src/**/*.ts
362
- pnpm format # prettier --write
363
- ```
364
-
365
- The repo uses:
366
-
367
- - **TypeScript 6** with NodeNext module resolution and `rewriteRelativeImportExtensions` (so source can import `./foo.ts` and emit `./foo.js`).
368
- - **ESLint 10 flat config** + `typescript-eslint`.
369
- - **Vitest 4** for tests.
370
- - **Semantic-release** for publishing.
371
- - **Commitlint** with conventional-commits.
372
-
373
- ### A note on `pnpm.peerDependencyRules`
478
+ ## Contributing
374
479
 
375
- `package.json` sets `pnpm.peerDependencyRules.allowedVersions` for several `@ganintegrity/*` packages and an `ignoreMissing` list for optional peers (`pg-native`, `tedious`, etc.). These suppress warnings caused by transitive packages declaring older peer ranges than the resolved versions. The suppression is intentional — the version skew is known and tolerated within the gan ecosystem. If a real peer mismatch appears in a future install, it'll stand out instead of being lost in noise.
480
+ See [CONTRIBUTING.md](./CONTRIBUTING.md) for the development loop, the
481
+ public-API contract (api-extractor + checked-in `.api.md` reports), how to
482
+ ship a breaking change, and commit conventions.
@@ -4,17 +4,24 @@ import type { Logger } from "pino";
4
4
  import type { AuthUser } from "./auth/auth.types.ts";
5
5
  import type { McpErrorMapper } from "./errors/errors.types.ts";
6
6
  /**
7
- * Internal per-request bundle. Exported only for advanced consumers who
8
- * write their own tool wrappers and need to read the active scope directly;
9
- * regular tool handlers receive `ToolContext` instead.
7
+ * Per-request bundle held inside the library's `AsyncLocalStorage`. Exported
8
+ * only for advanced consumers who write their own tool wrappers and need to
9
+ * read the active scope directly; regular tool handlers receive `ToolContext`
10
+ * instead.
11
+ *
12
+ * @public
10
13
  */
11
14
  export interface RequestStore {
15
+ /** Authenticated user resolved from the bearer token. */
12
16
  user: AuthUser;
17
+ /** Per-request Postgan instance, attached upstream by `setupPostgan`. */
13
18
  postgan: Postgan;
19
+ /** `X-Session-Id` header value, if the client supplied one. */
14
20
  sessionId?: string;
21
+ /** Logger childed with `component: "mcp"` plus per-request scope. */
15
22
  logger: Logger;
16
23
  /**
17
- * Translate thrown errors into MCP envelopes. Set by `createMcpServer`
24
+ * Translate thrown errors into MCP envelopes. Set by `defineMcpServer`
18
25
  * from its `errorMapper` option; tool wrappers use it before falling
19
26
  * through to the redacted `INTERNAL_ERROR` envelope.
20
27
  */
@@ -0,0 +1 @@
1
+ {"version":3,"file":"als.d.ts","sourceRoot":"","sources":["../../src/core/als.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AACrD,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,uBAAuB,CAAC;AACrD,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,sBAAsB,CAAC;AACrD,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAC;AAE/D;;;;;;;GAOG;AACH,MAAM,WAAW,YAAY;IAC3B,yDAAyD;IACzD,IAAI,EAAE,QAAQ,CAAC;IACf,yEAAyE;IACzE,OAAO,EAAE,OAAO,CAAC;IACjB,+DAA+D;IAC/D,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,qEAAqE;IACrE,MAAM,EAAE,MAAM,CAAC;IACf;;;;OAIG;IACH,WAAW,CAAC,EAAE,cAAc,CAAC;CAC9B;AAED,eAAO,MAAM,YAAY,iCAAwC,CAAC"}
@@ -0,0 +1 @@
1
+ {"version":3,"file":"als.js","sourceRoot":"","sources":["../../src/core/als.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,MAAM,kBAAkB,CAAC;AAgCrD,MAAM,CAAC,MAAM,YAAY,GAAG,IAAI,iBAAiB,EAAgB,CAAC"}
@@ -5,13 +5,19 @@ import type { Logger } from "pino";
5
5
  * richer service-specific user type. Inside tool handlers, `ctx.user` is
6
6
  * typed as `AuthUser`; cast to your service's richer type when you need
7
7
  * extra fields.
8
+ *
9
+ * @public
8
10
  */
9
11
  export interface AuthUser {
12
+ /** Stable identifier for the authenticated user. */
10
13
  id: string;
14
+ /** Tenant key — the user's company subdomain, used for downstream scoping. */
11
15
  companySubdomainName: string;
12
16
  }
13
17
  /**
14
18
  * Configuration for `mcpAuth`.
19
+ *
20
+ * @public
15
21
  */
16
22
  export interface McpAuthOptions {
17
23
  /**
@@ -0,0 +1 @@
1
+ {"version":3,"file":"auth.types.d.ts","sourceRoot":"","sources":["../../../src/core/auth/auth.types.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,MAAM,CAAC;AAEnC;;;;;;;;GAQG;AACH,MAAM,WAAW,QAAQ;IACvB,oDAAoD;IACpD,EAAE,EAAE,MAAM,CAAC;IACX,8EAA8E;IAC9E,oBAAoB,EAAE,MAAM,CAAC;CAC9B;AAED;;;;GAIG;AACH,MAAM,WAAW,cAAc;IAC7B;;;;;;;OAOG;IACH,WAAW,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,OAAO,CAAC,QAAQ,CAAC,CAAC;IAClD,oEAAoE;IACpE,MAAM,EAAE,MAAM,CAAC;CAChB"}
@@ -1 +1 @@
1
- {"version":3,"file":"auth.types.js","sourceRoot":"","sources":["../../src/auth/auth.types.ts"],"names":[],"mappings":""}
1
+ {"version":3,"file":"auth.types.js","sourceRoot":"","sources":["../../../src/core/auth/auth.types.ts"],"names":[],"mappings":""}
@@ -0,0 +1,34 @@
1
+ import type { AuthUser, McpAuthOptions } from "./auth.types.ts";
2
+ /**
3
+ * Framework-neutral header bag. Both Express's `req.headers` and Koa's
4
+ * `ctx.headers` satisfy this shape — Node lowercases the keys at the HTTP
5
+ * layer, so `"authorization"` and `"x-session-id"` reads work for either.
6
+ */
7
+ type HeaderBag = Record<string, string | string[] | undefined>;
8
+ type AuthResult = {
9
+ ok: true;
10
+ user: AuthUser;
11
+ token: string;
12
+ sessionId?: string;
13
+ } | {
14
+ ok: false;
15
+ status: 401;
16
+ message: string;
17
+ };
18
+ /**
19
+ * Resolve a request's auth from its headers. Reads `Authorization: Bearer
20
+ * <token>` and (optionally) `X-Session-Id`, then calls `tokenToUser` to
21
+ * resolve the token to an {@link AuthUser}.
22
+ *
23
+ * Returns a discriminated union — never throws. Per-framework adapters
24
+ * translate the result into a 401 response or a request-augmenting `next()`.
25
+ *
26
+ * - Missing/invalid Authorization header → `{ ok: false, status: 401,
27
+ * message: "Missing or invalid Authorization header" }`.
28
+ * - `tokenToUser` throws → `{ ok: false, status: 401, message:
29
+ * "Authentication failed: <reason>" }`. The throwable is logged at `warn`
30
+ * on `options.logger` before the result is returned.
31
+ */
32
+ export declare function resolveAuth(headers: HeaderBag, options: McpAuthOptions): Promise<AuthResult>;
33
+ export {};
34
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/core/auth/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAIhE;;;;GAIG;AACH,KAAK,SAAS,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,EAAE,GAAG,SAAS,CAAC,CAAC;AAE/D,KAAK,UAAU,GACX;IAAE,EAAE,EAAE,IAAI,CAAC;IAAC,IAAI,EAAE,QAAQ,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAAE,GAC/D;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,MAAM,EAAE,GAAG,CAAC;IAAC,OAAO,EAAE,MAAM,CAAA;CAAE,CAAC;AAiBhD;;;;;;;;;;;;;GAaG;AACH,wBAAsB,WAAW,CAC/B,OAAO,EAAE,SAAS,EAClB,OAAO,EAAE,cAAc,GACtB,OAAO,CAAC,UAAU,CAAC,CA0BrB"}
@@ -0,0 +1,57 @@
1
+ const BEARER_PREFIX = /^Bearer\s+/i;
2
+ function extractBearer(headers) {
3
+ const auth = headers["authorization"];
4
+ if (typeof auth === "string" && BEARER_PREFIX.test(auth)) {
5
+ return auth.replace(BEARER_PREFIX, "").trim();
6
+ }
7
+ return undefined;
8
+ }
9
+ function extractSessionId(headers) {
10
+ const sessionId = headers["x-session-id"];
11
+ return typeof sessionId === "string" && sessionId.length > 0
12
+ ? sessionId
13
+ : undefined;
14
+ }
15
+ /**
16
+ * Resolve a request's auth from its headers. Reads `Authorization: Bearer
17
+ * <token>` and (optionally) `X-Session-Id`, then calls `tokenToUser` to
18
+ * resolve the token to an {@link AuthUser}.
19
+ *
20
+ * Returns a discriminated union — never throws. Per-framework adapters
21
+ * translate the result into a 401 response or a request-augmenting `next()`.
22
+ *
23
+ * - Missing/invalid Authorization header → `{ ok: false, status: 401,
24
+ * message: "Missing or invalid Authorization header" }`.
25
+ * - `tokenToUser` throws → `{ ok: false, status: 401, message:
26
+ * "Authentication failed: <reason>" }`. The throwable is logged at `warn`
27
+ * on `options.logger` before the result is returned.
28
+ */
29
+ export async function resolveAuth(headers, options) {
30
+ const token = extractBearer(headers);
31
+ if (!token) {
32
+ return {
33
+ ok: false,
34
+ status: 401,
35
+ message: "Missing or invalid Authorization header",
36
+ };
37
+ }
38
+ try {
39
+ const user = await options.tokenToUser(token);
40
+ return {
41
+ ok: true,
42
+ user,
43
+ token,
44
+ sessionId: extractSessionId(headers),
45
+ };
46
+ }
47
+ catch (err) {
48
+ const reason = err instanceof Error ? err.message : "invalid token";
49
+ options.logger.warn({ err }, "mcp auth: token rejected");
50
+ return {
51
+ ok: false,
52
+ status: 401,
53
+ message: `Authentication failed: ${reason}`,
54
+ };
55
+ }
56
+ }
57
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../../../src/core/auth/index.ts"],"names":[],"mappings":"AAEA,MAAM,aAAa,GAAG,aAAa,CAAC;AAapC,SAAS,aAAa,CAAC,OAAkB;IACvC,MAAM,IAAI,GAAG,OAAO,CAAC,eAAe,CAAC,CAAC;IACtC,IAAI,OAAO,IAAI,KAAK,QAAQ,IAAI,aAAa,CAAC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC;QACzD,OAAO,IAAI,CAAC,OAAO,CAAC,aAAa,EAAE,EAAE,CAAC,CAAC,IAAI,EAAE,CAAC;IAChD,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC;AAED,SAAS,gBAAgB,CAAC,OAAkB;IAC1C,MAAM,SAAS,GAAG,OAAO,CAAC,cAAc,CAAC,CAAC;IAC1C,OAAO,OAAO,SAAS,KAAK,QAAQ,IAAI,SAAS,CAAC,MAAM,GAAG,CAAC;QAC1D,CAAC,CAAC,SAAS;QACX,CAAC,CAAC,SAAS,CAAC;AAChB,CAAC;AAED;;;;;;;;;;;;;GAaG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAC/B,OAAkB,EAClB,OAAuB;IAEvB,MAAM,KAAK,GAAG,aAAa,CAAC,OAAO,CAAC,CAAC;IACrC,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,OAAO;YACL,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,GAAG;YACX,OAAO,EAAE,yCAAyC;SACnD,CAAC;IACJ,CAAC;IACD,IAAI,CAAC;QACH,MAAM,IAAI,GAAG,MAAM,OAAO,CAAC,WAAW,CAAC,KAAK,CAAC,CAAC;QAC9C,OAAO;YACL,EAAE,EAAE,IAAI;YACR,IAAI;YACJ,KAAK;YACL,SAAS,EAAE,gBAAgB,CAAC,OAAO,CAAC;SACrC,CAAC;IACJ,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,MAAM,MAAM,GAAG,GAAG,YAAY,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC,CAAC,eAAe,CAAC;QACpE,OAAO,CAAC,MAAM,CAAC,IAAI,CAAC,EAAE,GAAG,EAAE,EAAE,0BAA0B,CAAC,CAAC;QACzD,OAAO;YACL,EAAE,EAAE,KAAK;YACT,MAAM,EAAE,GAAG;YACX,OAAO,EAAE,0BAA0B,MAAM,EAAE;SAC5C,CAAC;IACJ,CAAC;AACH,CAAC"}