@ganintegrity/mcp
v2.2.4
Published
Provides tooling to scaffold an MCP server
Maintainers
Readme
@ganintegrity/mcp
A small library for mounting a Model Context Protocol server onto an Express or Koa 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.
Why this library exists
The MCP TypeScript SDK gives you a McpServer and a StreamableHTTPServerTransport, but several things sit awkwardly between the SDK and a real Express service:
The transport is single-use.
StreamableHTTPServerTransport(in stateless mode) cannot be reused across requests. So you can't just instantiate oneMcpServerat boot and forward requests to it — you need a fresh server+transport per HTTP call, with the same tools registered on each one.Tool handlers need request-scoped state. A tool needs the authenticated user, a database client (postgan), the request's session id, and a correlation logger — none of which the MCP SDK knows about. Threading these through every tool call is noisy; AsyncLocalStorage is the natural fit.
Tool calls want database transactions. Each tool should run inside its own transaction so that a failure rolls back automatically. This is boilerplate every consumer would write the same way.
Errors need to become MCP envelopes. When a tool throws, the agent on the other end should see a structured
CallToolResultwithcode/message/details, not a stack trace. The library handles the envelope; your service supplies a smallerrorMappercallback that translates whatever it throws into a shape the library can emit.
This library packages (1)–(4) and asks the caller to plug in the parts that genuinely differ between services.
Concepts
A two-minute mental model before you read the API:
Define-once, materialize-per-request
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.
You write tool registrations once. The library handles the per-request lifecycle.
The ALS scope
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.
Per-tool transactions
When a tool fires, the helper opens a Postgan.Transaction, runs your handler, and commits on success or rolls back on throw. Your handler just calls ctx.transaction.query(...) (or ctx.postgan.something.do(...)) and never has to think about commit/rollback.
Error envelopes
When a handler throws, the library passes the throwable to your errorMapper. If the mapper returns an McpToolError shape, the library produces a CallToolResult with that code/message/details, logging at info (severity: "warn") or error (severity: "error"). If the mapper returns null — or if you didn't supply one — the library emits a generic INTERNAL_ERROR envelope with a redacted message and logs the original throwable at error level.
Installation
pnpm add @ganintegrity/mcpPeer dependencies (must exist in your service):
| Peer | Range | Needed for |
| ----------------------------------- | ------------ | --------------- |
| @ganintegrity/postgan | ^28 | always |
| @ganintegrity/postgres-migrations | ^16 | always |
| pino | ^10 | always |
| zod | ^4 | always |
| express | ^4 \|\| ^5 | /express only |
| koa | ^2 \|\| ^3 | /koa only |
The framework peers are declared optional — install the one matching the subpath you import. TypeScript consumers also need the matching types package (@types/express ^4 || ^5, or @types/koa ^2 || ^3) — optional peers as well, so JS consumers aren't forced to pull them in.
The express
^4range is transitional. The library targets Express 5; v4 support is provided so services mid-migration aren't blocked. Expect it to be dropped once consumers are on v5.
The library does not bundle any of these — your service is expected to already have them.
Quick start
// src/mcp/bootstrap.ts
import express from "express";
import { defineMcpServer, type McpErrorMapper } from "@ganintegrity/mcp";
import { createMcpRouter } from "@ganintegrity/mcp/express";
import { z } from "zod";
import { logger } from "../logger.ts";
import { AppError } from "../errors.ts";
import { decipherToken } from "../middleware/verify.js";
import identity from "../middleware/identity/identitymodel.js";
import setupPostgan from "../middleware/setupPostgan.js";
const errorMapper: McpErrorMapper = (err) => {
if (err instanceof AppError) {
return {
code: err.code,
message: err.message,
severity: err.statusCode >= 500 ? "error" : "warn",
details: err.details,
cause: err.cause,
};
}
return null;
};
// Register tools at boot, inside the `tools` callback. The callback runs
// once at define time; the registrations are replayed onto a fresh SDK
// `McpServer` per HTTP request.
const mcp = defineMcpServer({
name: "tprm-mcp",
version: "1.0.0",
errorMapper,
tools(register) {
register.tool({
name: "summarise_entity",
description: "Produce a short summary of an entity by uuid.",
inputSchema: z.object({ uuid: z.string().uuid() }),
handler: async ({ uuid }, ctx) => {
const entity = await ctx.postgan.entity.fetch({ uuid });
return { summary: entity.name, fetchedBy: ctx.user.id };
},
});
},
});
// Mount onto an Express app.
const mcpRouter = createMcpRouter(mcp, {
logger,
setupPostgan: setupPostgan(),
tokenToUser: async (token) => {
const decoded = await decipherToken(token);
return identity.construct({ ...decoded, id: decoded._id });
},
});
app.use("/mcp", mcpRouter);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.
Framework adapters
The library is split into a framework-neutral core and per-framework adapters. Consumers import from a subpath that matches their HTTP framework.
| Import | Status | Use |
| --------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------------- |
| @ganintegrity/mcp | Implemented | Framework-neutral surface: defineMcpServer, materializeSdkServer, error envelope plumbing, every shared type. |
| @ganintegrity/mcp/express | Implemented | Express ^4 \|\| ^5 adapter: createMcpRouter, mcpAuth, CreateMcpRouterOptions. |
| @ganintegrity/mcp/koa | Implemented | Koa ^2 \|\| ^3 adapter: createMcpMiddleware, mcpAuth, CreateMcpMiddlewareOptions, KoaState. |
A bootstrap file imports from both — framework-neutral helpers from the root, the adapter factory from /express or /koa. A tool definition file imports from the root only; it stays adapter-agnostic.
Importing from /express loads Express's ambient type augmentation (Express.Request gains mcpSessionId / auth). The /koa entry has no global augmentation — Koa state stays on ctx.state, typed via the exported KoaState interface. The root entry has no framework side effects at all.
The shared core lives under src/core/ and contains:
defineMcpServer({ name, version, errorMapper, tools })— the public entry point. Captures tool registrations into anMcpDefinitiononce at define time.materializeSdkServer(mcp): McpServer— builds a fresh SDKMcpServerand 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.resolveAuth(headers, options): AuthResult— extracts the bearer token + session id from a header bag, callstokenToUser, returns a discriminated success/failure union.dispatchMcpRequest(mcp, logger, scope, invokeTransport, onTransportError)— owns the per-request lifecycle: builds theRequestStore, materializes the SDK server, runs the framework-supplied transport invocation insiderequestStore.run(...), tears everything down onfinally.- The error envelope plumbing (
toCallToolError,INTERNAL_ERROR) and the postgan-transaction wrapper.
Each adapter is a thin layer that bridges its native request/response shape to these neutral helpers.
Koa usage
The Koa adapter mirrors the Express one — same options shape, same observable behaviour, same ToolContext inside tool handlers:
// src/mcp/bootstrap.ts
import Router from "koa-router"; // or @koa/router — anything that mounts middleware
import { defineMcpServer, type McpErrorMapper } from "@ganintegrity/mcp";
import { createMcpMiddleware } from "@ganintegrity/mcp/koa";
const mcp = defineMcpServer({
name: "workflow-mcp",
version: "1.0.0",
errorMapper,
tools(register) {
register.tool({
/* identical to express — same ToolContext, same handler signature */
});
},
});
const mcpMiddleware = createMcpMiddleware(mcp, {
logger,
setupPostgan: setupPostganKoa(), // Koa Middleware that sets ctx.state.postgan
tokenToUser: async (token) => {
/* same shape as express */
},
});
const router = new Router();
router.post("/mcp", mcpMiddleware);
app.use(router.routes());Differences from the Express adapter:
- Returns a plain
Middleware, not a router. The MCP endpoint is a singlePOSThandler, so the adapter doesn't impose a router dependency — mount it with whatever router your service already uses (koa-router,@koa/router) or underkoa-mount. The middleware only handlesPOST(matching the Express adapter'srouter.post); any other method short-circuits tonext(), so even underkoa-mount(which forwards every method) non-POSTrequests fall through to the rest of your stack rather than hitting auth/dispatch. - No body parser needed. The Express adapter mounts
express.json(); the Koa adapter mounts nothing — the MCP SDK transport reads and parses the raw request stream itself. If an upstream bodyparser already consumed the stream, the adapter forwards the pre-parsedctx.request.bodyto the transport instead, so both setups work. - State lives on
ctx.state.mcpAuthsetsctx.state.user(andctx.state.mcpSessionIdwhen theX-Session-Idheader is present); yoursetupPostgansetsctx.state.postgan. The exportedKoaStateinterface documents the contract. The SDK-shapedAuthInfogoes on the rawctx.req.auth, where the transport reads it. ctx.respond = false. The dispatcher opts out of Koa's response handling before delegating — the SDK transport writes directly to the Node response (including SSE streaming). The 500 fallback on transport error therefore also writes to the raw response.- No global type augmentation. Unlike
/express, importing/koaadds nothing to your globals.
Tool handlers see the same ToolContext regardless of adapter — the framework-specific bits stop at the auth + dispatch boundary.
API reference
Each export below is annotated with the subpath it lives on —
@ganintegrity/mcp(framework-neutral) or@ganintegrity/mcp/express(Express adapter). See Framework adapters for the full split.
defineMcpServer(options) — @ganintegrity/mcp
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.
Options:
| Field | Type | Description |
| -------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| name | string | MCP server name advertised to clients. |
| version | string | MCP server version advertised to clients. |
| tools | (register: ToolRegister) => void | Registration callback. Call register.tool(spec) once per tool. Anything beyond registration runs once at define time, not per request. |
| 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. |
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.
register.tool(spec) — @ganintegrity/mcp
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.
const mcp = defineMcpServer({
name: "tprm-mcp",
version: "1.0.0",
errorMapper,
tools(register) {
register.tool({
name: "tool_name",
description: "What this tool does",
inputSchema: z.object({
/* ... */
}),
annotations: { readOnlyHint: true }, // optional
handler: async (args, ctx) => {
// ctx.user, ctx.postgan, ctx.transaction, ctx.sessionId, ctx.logger
return {
/* plain object — surfaced as both text and structuredContent */
};
},
});
},
});ToolContext (the second handler argument):
| Field | Type |
| ------------- | ---------------------------------------------------------- |
| user | AuthUser (cast to your richer type as needed) |
| postgan | Postgan |
| transaction | PostganTransaction (auto-committed/rolled-back) |
| sessionId | string \| undefined |
| logger | pino.Logger (already child'd with tool name + sessionId) |
Annotations are MCP spec hints surfaced during tools/list:
| Field | Meaning |
| ----------------- | ------------------------------------ |
| title | Human-readable title |
| readOnlyHint | Tool only reads, never writes |
| destructiveHint | Tool may delete or destroy data |
| idempotentHint | Calling twice with same args is safe |
| openWorldHint | Tool reaches out to external systems |
createMcpRouter(mcp, options) — @ganintegrity/mcp/express
Build an Express Router that serves the supplied McpDefinition. Mount on whatever path the caller likes:
const mcpRouter = createMcpRouter(mcp, {
logger,
setupPostgan: setupPostgan(),
tokenToUser: async (token) => {
/* ... */
},
});
app.use("/mcp", mcpRouter);Options:
| Field | Type | Description |
| -------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| logger | pino.Logger | Service logger. The library calls logger.child({ component: "mcp" }) internally and further childs per request and per tool call. |
| tokenToUser | (token: string) => Promise<AuthUser> | Resolve a bearer token to a user. Reject by throwing — the router responds 401. |
| setupPostgan | RequestHandler | Express middleware that attaches a Postgan instance to req.postgan. The router mounts it between auth and the JSON-RPC dispatcher. |
Returns: an express.Router with express.json() → mcpAuth → setupPostgan → JSON-RPC dispatch composed onto it. Each request runs through the chain once.
mcpAuth(options) — @ganintegrity/mcp/express
The auth middleware that createMcpRouter mounts internally. Exported in case you need to compose it differently.
mcpAuth({
tokenToUser: async (token) => {
/* ... */
},
logger: pinoLogger,
});Sets req.user, req.headers.company, req.auth (SDK-shaped AuthInfo), and optionally req.mcpSessionId (from X-Session-Id header).
createMcpMiddleware(mcp, options) — @ganintegrity/mcp/koa
Build a single composed Koa Middleware that serves the supplied McpDefinition. Mount on whatever path the caller likes, with whatever router the service already uses:
const mcpMiddleware = createMcpMiddleware(mcp, {
logger,
setupPostgan: setupPostganKoa(),
tokenToUser: async (token) => {
/* ... */
},
});
router.post("/mcp", mcpMiddleware);Options: same fields as createMcpRouter, except setupPostgan is a Koa Middleware that attaches a Postgan instance to ctx.state.postgan.
Returns: a Koa Middleware composing mcpAuth → setupPostgan → JSON-RPC dispatch. No body-parsing step — the SDK transport reads the raw request stream itself (a pre-parsed ctx.request.body from an upstream bodyparser is forwarded when present).
mcpAuth(options) — @ganintegrity/mcp/koa
The Koa flavour of the auth middleware, mounted internally by createMcpMiddleware and exported for custom composition. Same options shape as the Express one.
Sets ctx.state.user, ctx.headers.company, ctx.req.auth (SDK-shaped AuthInfo, on the raw Node request where the transport reads it), and optionally ctx.state.mcpSessionId (from X-Session-Id header). Responds 401 via ctx.status/ctx.body on failure without calling next.
Errors — @ganintegrity/mcp
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.
import {
INTERNAL_ERROR,
toCallToolError,
type McpErrorMapper,
type McpToolError,
} from "@ganintegrity/mcp";McpToolError— the shape an envelope is built from:interface McpToolError { code: string; // surfaced to the agent message: string; // surfaced to the agent severity: "warn" | "error"; // drives log level details?: Record<string, unknown>; // surfaced to the agent cause?: unknown; // logged at error level only, never surfaced }McpErrorMapper—(err: unknown) => McpToolError | null. Returnnullto fall through to a redactedINTERNAL_ERRORenvelope (the original message is not leaked to the agent).INTERNAL_ERROR— string constant, the only code the library itself emits.toCallToolError(mapped, originalErr, meta)— used internally byregister.tool(). Exported in case you build your own tool wrappers.
The register.tool() wrapper invokes the mapper and toCallToolError automatically when a handler throws — you typically don't call them directly.
Integration notes
Express type augmentation
The library augments Express.Request with two fields:
declare global {
namespace Express {
interface Request {
mcpSessionId?: string;
auth?: AuthInfo; // from @modelcontextprotocol/sdk
}
}
}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).
The Koa adapter performs no global augmentation — everything it sets lives on ctx.state (documented by the exported KoaState interface), except the SDK-shaped AuthInfo which goes on the raw ctx.req.auth.
The AuthUser contract
AuthUser is the minimal shape the library reads:
interface AuthUser {
id: string;
companySubdomainName: string;
}Your tokenToUser returns an object that satisfies this — typically your service's richer user type (RequestUser etc.) which already has both fields. Inside tool handlers, ctx.user is typed as AuthUser; cast to your richer type when you need extra fields:
handler: async (args, ctx) => {
const user = ctx.user as Express.Request["user"];
return { firstName: user.firstName };
};Postgan middleware contract
setupPostgan is invoked after auth, so the resolved user is populated (req.user on Express, ctx.state.user on Koa). Your middleware should read whatever it needs (typically userToken, postgresId, postgresCompanyId) off the user, build a Postgan, and assign it where the adapter reads it back: req.postgan (and conventionally res.locals.postgan) on Express, ctx.state.postgan on Koa.
If the postgan instance is missing when a request lands at the dispatch step, the library responds with 401 Unauthenticated — auth + setupPostgan are presumed to have run upstream.
Bridging your error type
Your service keeps its own AppError (or whatever throwable it already uses). The library only sees the bridge you supply via errorMapper. Typical shape:
import type { McpErrorMapper } from "@ganintegrity/mcp";
import { AppError } from "../errors.ts";
export const errorMapper: McpErrorMapper = (err) => {
if (err instanceof AppError) {
return {
code: err.code,
message: err.message,
severity: err.statusCode >= 500 ? "error" : "warn",
details: err.details,
cause: err.cause,
};
}
// Anything not recognised → null → redacted INTERNAL_ERROR envelope.
return null;
};Mapping rules to keep in mind:
severitydrives log level ("warn"→ info log,"error"→ error log) and is your call. Pick"warn"for failures the agent can react to (not found, validation, forbidden);"error"for anything you'd want a human to look at.messageanddetailsare surfaced to the agent. Don't put internals (DB error text, stack traces, internal IDs) here. Sanitise upstream if you need to.causeis logged but never surfaced. Use it to attach the original error for debugging without leaking it to the agent.- Returning
nulltriggers the redacted fallback:INTERNAL_ERRORcode, generic "An unexpected error occurred." message, original error logged at error level. Use this for anything you don't want the agent to see verbatim.
Architecture deep dive
Why defineMcpServer + materializeSdkServer instead of new McpServer()?
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.
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.
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.
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.
Why AsyncLocalStorage instead of passing context?
Tools could receive the Request/Response directly, and pull user/postgan/etc. off them. But that:
- 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.
- Forces every helper called by a tool to also receive the context, polluting helper signatures throughout the service.
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.
Why per-tool transactions?
A single MCP request can dispatch multiple tool calls. Wrapping the entire request in one transaction means a late-call failure rolls back work the agent already saw as successful. Wrapping each tool call in its own transaction gives the agent committed, observable side effects after each call — matching the "tool" mental model.
The cost is more transaction begin/commits, which on a healthy postgres is negligible.
Why register.tool(spec) instead of server.registerTool(...)?
register.tool(spec) is a thin wrapper around the SDK's registerTool that:
- Reads the ALS store to build a
ToolContext. - Opens a transaction, runs your handler, commits or rolls back.
- Maps thrown errors to
CallToolResultenvelopes.
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.
Contributing
See CONTRIBUTING.md for the development loop, the
public-API contract (api-extractor + checked-in .api.md reports), how to
ship a breaking change, and commit conventions.
