@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.
- package/README.md +191 -84
- package/dist/{als.d.ts → core/als.d.ts} +11 -4
- package/dist/core/als.d.ts.map +1 -0
- package/dist/core/als.js.map +1 -0
- package/dist/{auth → core/auth}/auth.types.d.ts +6 -0
- package/dist/core/auth/auth.types.d.ts.map +1 -0
- package/dist/{auth → core/auth}/auth.types.js.map +1 -1
- package/dist/core/auth/index.d.ts +34 -0
- package/dist/core/auth/index.d.ts.map +1 -0
- package/dist/core/auth/index.js +57 -0
- package/dist/core/auth/index.js.map +1 -0
- package/dist/core/define.d.ts +80 -0
- package/dist/core/define.d.ts.map +1 -0
- package/dist/core/define.js +52 -0
- package/dist/core/define.js.map +1 -0
- package/dist/core/dispatch.d.ts +35 -0
- package/dist/core/dispatch.d.ts.map +1 -0
- package/dist/core/dispatch.js +57 -0
- package/dist/core/dispatch.js.map +1 -0
- package/dist/{errors → core/errors}/errors.types.d.ts +21 -0
- package/dist/core/errors/errors.types.d.ts.map +1 -0
- package/dist/core/errors/errors.types.js.map +1 -0
- package/dist/{errors → core/errors}/index.d.ts +4 -0
- package/dist/core/errors/index.d.ts.map +1 -0
- package/dist/{errors → core/errors}/index.js +4 -0
- package/dist/core/errors/index.js.map +1 -0
- package/dist/core/test-helpers.d.ts +20 -0
- package/dist/core/test-helpers.d.ts.map +1 -0
- package/dist/core/test-helpers.js +38 -0
- package/dist/core/test-helpers.js.map +1 -0
- package/dist/core/tool/index.d.ts +19 -0
- package/dist/core/tool/index.d.ts.map +1 -0
- package/dist/{tool → core/tool}/index.js +28 -25
- package/dist/core/tool/index.js.map +1 -0
- package/dist/{tool → core/tool}/tool.types.d.ts +38 -2
- package/dist/core/tool/tool.types.d.ts.map +1 -0
- package/dist/{tool → core/tool}/tool.types.js.map +1 -1
- package/dist/{auth/index.d.ts → express/auth.d.ts} +10 -9
- package/dist/express/auth.d.ts.map +1 -0
- package/dist/express/auth.js +49 -0
- package/dist/express/auth.js.map +1 -0
- package/dist/express/express.types.d.ts +37 -0
- package/dist/express/express.types.d.ts.map +1 -0
- package/dist/express/express.types.js +2 -0
- package/dist/express/express.types.js.map +1 -0
- package/dist/express/index.d.ts +41 -0
- package/dist/express/index.d.ts.map +1 -0
- package/dist/express/index.js +59 -0
- package/dist/express/index.js.map +1 -0
- package/dist/index.d.ts +21 -18
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +10 -4
- package/dist/index.js.map +1 -1
- package/package.json +8 -1
- package/dist/als.d.ts.map +0 -1
- package/dist/als.js.map +0 -1
- package/dist/auth/auth.types.d.ts.map +0 -1
- package/dist/auth/index.d.ts.map +0 -1
- package/dist/auth/index.js +0 -74
- package/dist/auth/index.js.map +0 -1
- package/dist/errors/errors.types.d.ts.map +0 -1
- package/dist/errors/errors.types.js.map +0 -1
- package/dist/errors/index.d.ts.map +0 -1
- package/dist/errors/index.js.map +0 -1
- package/dist/server/index.d.ts +0 -18
- package/dist/server/index.d.ts.map +0 -1
- package/dist/server/index.js +0 -130
- package/dist/server/index.js.map +0 -1
- package/dist/server/server.types.d.ts +0 -78
- package/dist/server/server.types.d.ts.map +0 -1
- package/dist/server/server.types.js +0 -2
- package/dist/server/server.types.js.map +0 -1
- package/dist/tool/index.d.ts +0 -26
- package/dist/tool/index.d.ts.map +0 -1
- package/dist/tool/index.js.map +0 -1
- package/dist/tool/tool.types.d.ts.map +0 -1
- /package/dist/{als.js → core/als.js} +0 -0
- /package/dist/{auth → core/auth}/auth.types.js +0 -0
- /package/dist/{errors → core/errors}/errors.types.js +0 -0
- /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
|
-
###
|
|
27
|
+
### Define-once, materialize-per-request
|
|
30
28
|
|
|
31
|
-
`
|
|
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
|
|
31
|
+
You write tool registrations once. The library handles the per-request lifecycle.
|
|
34
32
|
|
|
35
33
|
### The ALS scope
|
|
36
34
|
|
|
37
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
228
|
+
### `defineMcpServer(options)` — `@ganintegrity/mcp`
|
|
134
229
|
|
|
135
|
-
**
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
243
|
+
### `register.tool(spec)` — `@ganintegrity/mcp`
|
|
154
244
|
|
|
155
|
-
|
|
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
|
-
|
|
159
|
-
name: "
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
### `
|
|
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
|
-
|
|
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()`
|
|
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
|
|
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
|
|
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 `
|
|
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
|
|
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 `
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
*
|
|
8
|
-
* write their own tool wrappers and need to
|
|
9
|
-
* regular tool handlers receive `ToolContext`
|
|
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 `
|
|
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":["
|
|
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"}
|