mcpresso
Version:
TypeScript package for Model Context Protocol (MCP) utilities and tools
1,167 lines (1,037 loc) β’ 41.7 kB
text/typescript
import express from "express";
import cors from "cors";
import rateLimit from "express-rate-limit";
import { z } from "zod";
import { zodToJsonSchema } from "zod-to-json-schema";
import { McpServer, ResourceTemplate, type RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import type { Express, Request, Response } from "express";
import type { CallToolResult, ReadResourceResult } from "@modelcontextprotocol/sdk/types.js";
import { type MCPAuthConfig, createMCPAuthMiddleware, createMCPProtectedResourceMetadataHandler, type UserProfile, type JWTPayload } from "./auth.js";
import { randomUUID } from "crypto";
import { AsyncLocalStorage } from "async_hooks";
import { registerOAuthEndpoints } from 'mcpresso-oauth-server';
import { MCPOAuthServer } from 'mcpresso-oauth-server';
// Request context using AsyncLocalStorage for proper isolation
interface RequestContext {
user?: UserProfile | JWTPayload;
sessionId?: string;
}
const requestContext = new AsyncLocalStorage<RequestContext>();
// Helper function to get current user from context
export function getCurrentUser(): UserProfile | JWTPayload | undefined {
return requestContext.getStore()?.user;
}
/**
* Configuration for exponential back-off retries on handler failures.
*/
export interface RetryConfig {
/** The maximum number of retry attempts.
* @default 3
*/
retries?: number;
/** The exponential factor to use.
* @default 2
*/
factor?: number;
/** The minimum (initial) timeout in milliseconds.
* @default 1000
*/
minTimeout?: number;
/** The maximum timeout in milliseconds.
* @default 30000
*/
maxTimeout?: number;
}
/**
* Configuration for basic rate limiting on incoming requests.
* Uses `express-rate-limit`.
*/
export interface RateLimitConfig {
/** The time window in milliseconds.
* @default 900000 (15 minutes)
*/
windowMs?: number;
/** The maximum number of requests to allow from an IP within the window.
* @default 100
*/
limit?: number;
/**
* Whether to send `RateLimit` and `RateLimit-Policy` headers.
* Can be a boolean or a specific draft version.
* @see https://www.npmjs.com/package/express-rate-limit#standardheaders
* @default true
*/
standardHeaders?: boolean | "draft-6" | "draft-7";
/**
* Whether to send legacy `X-RateLimit-*` headers.
* @default false
*/
legacyHeaders?: boolean;
}
/**
* Server metadata that can be exposed as an MCP resource.
*/
export interface ServerMetadata {
/** The name of the server. */
name: string;
/** The version of the server. */
version: string;
/** A description of the server. */
description?: string;
/** The canonical URL of the server. */
url?: string;
/** Contact information for the server maintainers. */
contact?: {
name?: string;
email?: string;
url?: string;
};
/** License information. */
license?: {
name: string;
url?: string;
};
/** Available resources and their descriptions. */
resources?: Array<{
name: string;
description?: string;
methods: string[];
}>;
/** Server capabilities and features. */
capabilities?: {
authentication?: boolean;
rateLimiting?: boolean;
retries?: boolean;
streaming?: boolean;
};
}
/**
* Configuration for dynamic tools management.
*/
export interface DynamicToolsConfig {
/** Whether to enable dynamic tools management. */
enabled?: boolean;
/** Minimum interval between tool changes in milliseconds. */
minInterval?: number;
/** Maximum interval between tool changes in milliseconds. */
maxInterval?: number;
/** Maximum number of dynamic tools to maintain. */
maxTools?: number;
/** List of tool names that can be dynamically added/removed. */
availableTools?: Array<{
name: string;
description: string;
inputSchema: z.ZodObject<any>;
handler: (args: any) => Promise<any>;
}>;
}
// --- Core Method and Resource Configuration ---
/** Represents a relationship to another resource type. */
interface Relation {
/** The name of the resource type being referenced. */
type: string;
}
/**
* The fully processed configuration for a single resource tool/method.
* This is generated by the `createResource` function.
* @internal
*/
interface Method {
description: string;
inputSchema: z.ZodObject<any, any, any>;
outputSchema: z.ZodTypeAny;
handler: (args: any, user?: any) => Promise<any>;
// A flag to indicate if the output should be enriched with URIs.
returnsResourceList?: boolean;
}
/**
* A user-defined method. The `createResource` factory uses this to build
* the final `Method` configuration.
*/
export type MethodDefinition<TInput extends z.ZodObject<any, any, any> | z.ZodTypeAny, TOutput> = {
description?: string;
inputSchema?: TInput;
outputSchema?: z.ZodType<TOutput>;
handler: (args: TInput extends z.ZodTypeAny ? z.infer<TInput> : any, user?: any) => Promise<TOutput>;
};
// --- Specialized Method Definitions for Automatic Schema Inference ---
type CreateMethod<T extends z.ZodObject<any>> = Omit<MethodDefinition<z.ZodObject<Omit<T["shape"], "id" | "createdAt" | "updatedAt">>, z.infer<T>>, "inputSchema" | "outputSchema"> & { handler: (args: z.infer<ReturnType<T["partial"]>>, user?: any) => Promise<z.infer<T>> };
type UpdateMethod<T extends z.ZodObject<any>> = Omit<MethodDefinition<z.ZodObject<any>, z.infer<T>>, "inputSchema" | "outputSchema"> & { handler: (args: z.infer<ReturnType<T["partial"]>> & { id: string }, user?: any) => Promise<z.infer<T>> };
type DeleteMethod<T extends z.ZodObject<any>> = Omit<MethodDefinition<z.ZodObject<{ id: z.ZodString }>, { success: boolean }>, "inputSchema" | "outputSchema"> & { handler: (args: { id: string }, user?: any) => Promise<{ success: boolean }> };
type ListMethod<T extends z.ZodObject<any>> = Omit<MethodDefinition<z.ZodObject<{}>, z.infer<T>[]>, "inputSchema" | "outputSchema"> & { handler: (args: {}, user?: any) => Promise<z.infer<T>[]> };
type GetMethod<T extends z.ZodObject<any>> = MethodDefinition<z.ZodObject<{ id: z.ZodString }>, z.infer<T> | undefined> & { handler: (args: { id: string }, user?: any) => Promise<z.infer<T> | undefined> };
/**
* The final, fully processed configuration for a resource.
* This is the output of the `createResource` factory function.
* @internal
*/
export interface ResourceConfig<T extends z.ZodObject<any, any, any>> {
name: string;
schema: T;
uri_template: string;
relations?: Partial<Record<keyof T["shape"], Relation>>;
get?: (args: { id: string }, user?: any) => Promise<z.infer<T> | undefined>;
methods: Record<string, Method>;
}
/**
* The user-facing blueprint for defining a resource and its methods.
* This object is passed to the `createResource` factory.
* @template T The main Zod schema for the resource.
*/
export interface ResourceBlueprint<T extends z.ZodObject<any, any, any>> {
/** The singular, lowercase name of the resource (e.g., "note", "user"). */
name: string;
/** The Zod schema that defines the resource's data structure and validation rules. */
schema: T;
/**
* A URI template for generating unique URIs for each resource instance.
* Must include an `{id}` placeholder. Example: "notes/{id}".
*/
uri_template: string;
/**
* Defines relationships to other resources. The keys must be fields in the schema.
* This is used to enrich the exposed type schemas with `$ref` links.
*/
relations?: Partial<Record<keyof T["shape"], Relation>>;
/**
* An object defining the tools (methods) for this resource.
* The library provides default schemas and descriptions for standard
* methods like `create`, `update`, `list`, and `delete`.
*/
methods?: {
get?: Partial<GetMethod<T>>;
create?: Partial<CreateMethod<T>> & { inputSchema?: z.ZodObject<any> };
update?: Partial<UpdateMethod<T>> & { inputSchema?: z.ZodObject<any> };
delete?: Partial<DeleteMethod<T>>;
list?: Partial<ListMethod<T>>;
[key: string]: Partial<MethodDefinition<any, any>> | undefined;
};
}
/** Configuration for the main MCP server. */
export interface MCPServerConfig {
/** The name of the server, used in resource URIs. */
name: string;
/** The public-facing canonical URL of this server. Used for authentication (as audience) and in resource URIs. */
serverUrl?: string;
/**
* An array of resource configurations.
* This is now an array of the fully processed `ResourceConfig` objects.
*/
resources: ResourceConfig<any>[];
/**
* If `true`, exposes JSON schemas for all resources.
* If an array is provided, exposes schemas only for those resources.
* This is crucial for enabling language models to understand the server's data model.
*/
exposeTypes?: boolean | ResourceConfig<any>[];
/**
* Optional configuration to enable authentication.
*
* Three modes:
* 1. No auth: Don't provide this field
* 2. External OAuth: Provide issuer + userLookup
* 3. Integrated OAuth: Provide oauth server instance
*/
auth?: MCPAuthConfig;
/** Optional configuration for exponential back-off retry on handler failures. */
retry?: RetryConfig;
/** Optional configuration for rate limiting. */
rateLimit?: RateLimitConfig;
/** Optional server metadata to expose as an MCP resource. */
serverMetadata?: ServerMetadata;
/** Optional configuration for dynamic tools management. */
// dynamicTools?: DynamicToolsConfig;
}
// --- Type Definitions for Exposed Types ---
// These are internal types used for structuring the exposed schema information.
interface TypeField {
name: string;
type: string;
description?: string;
relation?: string;
}
interface TypeResource {
name: string;
description?: string;
fields: TypeField[];
related_tools: string[];
}
// --- Helper Functions ---
/**
* A helper function that injects relationship metadata (`$ref` links) into a JSON schema.
* This is used to connect related resource types in the exposed schemas.
* @internal
*/
function applyRelationsToJsonSchema(jsonSchema: any, zodSchema: z.ZodObject<any>, relations: Partial<Record<string, Relation>> | undefined, serverName: string) {
if (!relations || !("properties" in jsonSchema)) {
return jsonSchema;
}
const newProperties = { ...jsonSchema.properties };
for (const fieldName in relations) {
if (newProperties[fieldName]) {
const relation = relations[fieldName];
if (relation) {
const relatedResourceName = relation.type;
const ref = `type://${serverName}/${relatedResourceName}`;
const fieldZodDef = zodSchema.shape[fieldName];
if (fieldZodDef instanceof z.ZodArray) {
newProperties[fieldName].items = { $ref: ref };
} else {
newProperties[fieldName] = { $ref: ref };
}
}
}
}
return { ...jsonSchema, properties: newProperties };
}
/**
* A helper function that wraps a promise-based function with exponential back-off retry logic.
* @internal
*/
async function withRetry<T>(fn: () => Promise<T>, retryConfig?: RetryConfig): Promise<T> {
if (!retryConfig) {
return fn();
}
// Set defaults for retry configuration
const { retries = 3, factor = 2, minTimeout = 1000, maxTimeout = 30000 } = retryConfig;
for (let attempt = 0; attempt <= retries; attempt++) {
try {
return await fn();
} catch (error) {
if (attempt === retries) {
console.error(`Handler failed after ${retries + 1} attempts.`, error);
throw error;
}
const timeout = Math.min(minTimeout * Math.pow(factor, attempt), maxTimeout);
console.log(`Attempt ${attempt + 1} failed. Retrying in ${timeout}ms...`);
await new Promise((resolve) => setTimeout(resolve, timeout));
}
}
// This line should be unreachable
throw new Error("Exhausted retry attempts");
}
/**
* Helper function to extract editable properties from a Zod schema.
* Properties marked with .readonly() are excluded from create/update operations.
*/
function extractEditableProperties<T extends z.ZodObject<any, any, any>>(schema: T): z.ZodObject<any> {
const shape = schema.shape;
const editableShape: Record<string, z.ZodTypeAny> = {};
for (const [key, value] of Object.entries(shape) as [string, z.ZodTypeAny][]) {
// Check if the property is readonly by examining the Zod type
// This is a heuristic - we look for the _def property that indicates readonly
const isReadonly = (value as any)._def?.typeName === "ZodReadonly" || (value as any)._def?.innerType?._def?.typeName === "ZodReadonly";
if (!isReadonly) {
editableShape[key] = value;
}
}
return z.object(editableShape);
}
/**
* Helper function to create a schema for create operations.
* Excludes readonly properties and common server-managed fields.
*/
function createCreateSchema<T extends z.ZodObject<any, any, any>>(schema: T): z.ZodObject<any> {
const editableSchema = extractEditableProperties(schema);
// Also exclude common server-managed fields that shouldn't be in create operations
const serverManagedFields = ["id", "createdAt", "updatedAt"];
const createShape: Record<string, z.ZodTypeAny> = {};
for (const [key, value] of Object.entries(editableSchema.shape) as [string, z.ZodTypeAny][]) {
if (!serverManagedFields.includes(key)) {
createShape[key] = value;
}
}
return z.object(createShape);
}
/**
* Helper function to create a schema for update operations.
* Excludes readonly properties but allows partial updates.
*/
function createUpdateSchema<T extends z.ZodObject<any, any, any>>(schema: T): z.ZodObject<any> {
const editableSchema = extractEditableProperties(schema);
// Make all editable properties optional for updates
const updateShape: Record<string, z.ZodTypeAny> = {};
for (const [key, value] of Object.entries(editableSchema.shape) as [string, z.ZodTypeAny][]) {
// Don't make id optional as it's required for updates
if (key === "id") {
updateShape[key] = value;
} else {
updateShape[key] = value.optional();
}
}
return z.object(updateShape);
}
/**
* Manages dynamic tools that can be added and removed at runtime.
* Sends notifications when the tools list changes.
*/
// class DynamicToolsManager {
// private server: McpServer;
// private config: DynamicToolsConfig;
// private activeTools: Set<string> = new Set();
// private registeredTools: Set<string> = new Set();
// private registeredToolObjects: Map<string, RegisteredTool> = new Map();
// private intervalId?: NodeJS.Timeout;
// private toolCounter = 0;
// constructor(server: McpServer, config: DynamicToolsConfig) {
// this.server = server;
// this.config = {
// enabled: false,
// minInterval: 10000, // 10 seconds
// maxInterval: 30000, // 30 seconds
// maxTools: 5,
// availableTools: [
// {
// name: "dynamic_ping",
// description: "A dynamic ping tool that responds with a timestamp",
// inputSchema: z.object({}),
// handler: async () => ({ message: "pong", timestamp: new Date().toISOString() }),
// },
// {
// name: "dynamic_echo",
// description: "A dynamic echo tool that repeats the input",
// inputSchema: z.object({ message: z.string() }),
// handler: async (args: { message: string }) => ({ echoed: args.message, timestamp: new Date().toISOString() }),
// },
// {
// name: "dynamic_random",
// description: "A dynamic tool that generates random numbers",
// inputSchema: z.object({ min: z.number().optional(), max: z.number().optional() }),
// handler: async (args: { min?: number; max?: number }) => {
// const min = args.min ?? 1;
// const max = args.max ?? 100;
// return { random: Math.floor(Math.random() * (max - min + 1)) + min };
// },
// },
// {
// name: "dynamic_weather",
// description: "A dynamic weather tool that simulates weather data",
// inputSchema: z.object({ city: z.string() }),
// handler: async (args: { city: string }) => ({
// city: args.city,
// temperature: Math.floor(Math.random() * 30) + 10,
// condition: ["sunny", "cloudy", "rainy", "snowy"][Math.floor(Math.random() * 4)],
// timestamp: new Date().toISOString(),
// }),
// },
// {
// name: "dynamic_calculator",
// description: "A dynamic calculator tool",
// inputSchema: z.object({ operation: z.enum(["add", "subtract", "multiply", "divide"]), a: z.number(), b: z.number() }),
// handler: async (args: { operation: string; a: number; b: number }) => {
// let result: number;
// switch (args.operation) {
// case "add": result = args.a + args.b; break;
// case "subtract": result = args.a - args.b; break;
// case "multiply": result = args.a * args.b; break;
// case "divide": result = args.a / args.b; break;
// default: throw new Error("Invalid operation");
// }
// return { result, operation: args.operation, a: args.a, b: args.b };
// },
// },
// ],
// ...config,
// };
// }
// /**
// * Starts the dynamic tools manager.
// */
// start() {
// if (!this.config.enabled || this.intervalId) {
// return;
// }
// console.log("π Starting dynamic tools manager...");
// this.scheduleNextChange();
// }
// /**
// * Stops the dynamic tools manager.
// */
// stop() {
// if (this.intervalId) {
// clearTimeout(this.intervalId);
// this.intervalId = undefined;
// console.log("π Stopped dynamic tools manager");
// }
// }
// /**
// * Schedules the next tool change.
// */
// private scheduleNextChange() {
// const interval = Math.random() * (this.config.maxInterval! - this.config.minInterval!) + this.config.minInterval!;
// this.intervalId = setTimeout(() => {
// this.performRandomChange();
// this.scheduleNextChange();
// }, interval);
// }
// /**
// * Performs a random tool change (add or remove).
// */
// private performRandomChange() {
// const shouldAdd = Math.random() > 0.5;
// if (shouldAdd && this.activeTools.size < this.config.maxTools!) {
// this.addRandomTool();
// } else if (this.activeTools.size > 0) {
// this.removeRandomTool();
// }
// }
// /**
// * Adds a random tool from the available tools.
// */
// private addRandomTool() {
// const availableTools = this.config.availableTools!;
// const availableForAddition = availableTools.filter(tool => !this.activeTools.has(tool.name));
// if (availableForAddition.length === 0) {
// return;
// }
// const toolToAdd = availableForAddition[Math.floor(Math.random() * availableForAddition.length)];
// this.addTool(toolToAdd);
// }
// /**
// * Removes a random active tool.
// */
// private removeRandomTool() {
// const activeToolsArray = Array.from(this.activeTools);
// const toolToRemove = activeToolsArray[Math.floor(Math.random() * activeToolsArray.length)];
// this.removeTool(toolToRemove);
// }
// /**
// * Adds a specific tool.
// */
// private addTool(toolDef: { name: string; description: string; inputSchema: z.ZodObject<any>; handler: (args: any) => Promise<any> }) {
// if (this.activeTools.has(toolDef.name)) {
// return;
// }
// // Only register the tool if it hasn't been registered before
// if (!this.registeredTools.has(toolDef.name)) {
// const reg = this.server.tool(toolDef.name, toolDef.description, toolDef.inputSchema.shape, async (args: any) => {
// const result = await toolDef.handler(args);
// return { content: [{ type: "text" as const, text: JSON.stringify(result) }] };
// });
// this.registeredTools.add(toolDef.name);
// this.registeredToolObjects.set(toolDef.name, reg);
// } else {
// // If it was registered before, just re-enable it
// const reg = this.registeredToolObjects.get(toolDef.name);
// if (reg) {
// reg.enabled = true;
// }
// }
// this.activeTools.add(toolDef.name);
// console.log(`β Added dynamic tool: ${toolDef.name}`);
// // Send notification about tools list change
// this.sendToolsListChangedNotification();
// }
// /**
// * Removes a specific tool.
// */
// private removeTool(toolName: string) {
// if (!this.activeTools.has(toolName)) {
// return;
// }
// this.activeTools.delete(toolName);
// const reg = this.registeredToolObjects.get(toolName);
// if (reg) {
// reg.enabled = false; // hide from tools/list
// }
// console.log(`β Removed dynamic tool: ${toolName}`);
// // Send notification about tools list change
// this.sendToolsListChangedNotification();
// }
// /**
// * Sends a notification that the tools list has changed.
// * Note: Due to the current transport architecture (new transport per request),
// * notifications are logged but may not reach clients immediately.
// * Clients should periodically call tools/list to get the current state.
// */
// private sendToolsListChangedNotification() {
// try {
// // Log the notification for debugging
// console.log(`π‘ Tools list changed notification (active tools: ${Array.from(this.activeTools).join(", ")})`);
// console.log(`π‘ Note: Due to transport architecture, clients should call 'tools/list' to get updated tool list`);
// // Try to send through the server if connected
// if (this.server.isConnected()) {
// try {
// this.server.sendToolListChanged();
// } catch (err: any) {
// if (err?.code === "ERR_STREAM_WRITE_AFTER_END") {
// console.warn("π SSE stream already closed. Skipping notification.");
// } else {
// throw err;
// }
// }
// }
// } catch (error) {
// console.warn("Failed to send tools list changed notification:", error);
// }
// }
// /**
// * Gets the current list of active dynamic tools.
// */
// getActiveTools(): string[] {
// return Array.from(this.activeTools);
// }
// /**
// * Manually adds a tool by name.
// */
// manuallyAddTool(toolName: string) {
// const toolDef = this.config.availableTools!.find(t => t.name === toolName);
// if (toolDef) {
// this.addTool(toolDef);
// }
// }
// /**
// * Manually removes a tool by name.
// */
// manuallyRemoveTool(toolName: string) {
// this.removeTool(toolName);
// }
// }
// --- Core Implementation ---
/**
* A factory function for creating a resource configuration object.
* Provides type inference and helps ensure the configuration is valid.
* @param config The resource configuration.
* @returns The validated resource configuration object.
*/
export function createResource<T extends z.ZodObject<any, any, any>>(blueprint: ResourceBlueprint<T>): ResourceConfig<T> {
const { name, schema, methods: methodBlueprints } = blueprint;
const processedMethods: Record<string, Method> = {};
let getHandler: ((args: { id: string }) => Promise<z.infer<T> | undefined>) | undefined;
if (methodBlueprints) {
// Handle the 'get' method as a special case first.
if (methodBlueprints.get?.handler) {
getHandler = methodBlueprints.get.handler as any;
// Don't delete it - we'll process it as a regular method too
}
for (const methodName in methodBlueprints) {
const methodDef = methodBlueprints[methodName as keyof typeof methodBlueprints] as any;
if (!methodDef?.handler) continue;
let { description, inputSchema, outputSchema, handler } = methodDef;
let returnsResourceList = false;
// Apply defaults for standard methods
switch (methodName) {
case "get":
description ??= `Get a ${name} by its ID.`;
inputSchema ??= z.object({ id: z.string() });
outputSchema ??= schema.optional();
break;
case "create":
description ??= `Create a new ${name}.`;
inputSchema ??= createCreateSchema(schema);
outputSchema ??= schema;
break;
case "update":
description ??= `Update an existing ${name}.`;
inputSchema ??= createUpdateSchema(schema);
outputSchema ??= schema;
break;
case "delete":
description ??= `Delete a ${name} by its ID.`;
inputSchema ??= z.object({ id: z.string() });
outputSchema ??= z.object({ success: z.boolean() });
break;
case "list":
description ??= `List all ${name}s.`;
inputSchema ??= z.object({});
outputSchema ??= z.array(schema);
returnsResourceList = true;
break;
default: // Custom method or search
if (!description) throw new Error(`Method '${methodName}' for resource '${name}' must have a description.`);
if (!inputSchema) throw new Error(`Method '${methodName}' for resource '${name}' must have an inputSchema.`);
outputSchema ??= z.any();
if (methodName === "search" || (outputSchema instanceof z.ZodArray && outputSchema.element === schema)) {
returnsResourceList = true;
}
break;
}
processedMethods[methodName] = { description, inputSchema, outputSchema, handler, returnsResourceList };
}
}
return {
name: blueprint.name,
schema: blueprint.schema,
uri_template: blueprint.uri_template,
relations: blueprint.relations,
get: getHandler,
methods: processedMethods,
};
}
/**
* Creates an Express app instance that serves a fully-functional MCP server.
* @param config The server configuration object.
* @returns An Express application instance.
*/
export function createMCPServer(config: MCPServerConfig): Express {
const app = express();
// Apply CORS globally for all endpoints (OAuth + MCP)
app.use(cors({ origin: "*" }));
// Parse x-www-form-urlencoded bodies (needed for /token requests)
app.use(express.urlencoded({ extended: true }));
// Parse JSON bodies (for MCP requests)
app.use(express.json());
// Add rate limiting if configured
if (config.rateLimit) {
const limiter = rateLimit({
windowMs: config.rateLimit.windowMs,
limit: config.rateLimit.limit,
standardHeaders: config.rateLimit.standardHeaders,
legacyHeaders: config.rateLimit.legacyHeaders,
});
app.use(limiter);
}
if (config.auth) {
// Use custom metadata endpoint if provided, otherwise default to the RFC-defined path
const metadataPath = config.auth.metadataEndpoint || "/.well-known/oauth-protected-resource";
app.get(metadataPath, createMCPProtectedResourceMetadataHandler(config.auth, config.serverUrl));
}
// --- OAuth server integration (integrated mode) ---
if (config.auth?.oauth) {
const oauthServer = config.auth.oauth as MCPOAuthServer;
registerOAuthEndpoints(app, oauthServer, '');
}
const authMiddleware = config.auth ? createMCPAuthMiddleware(config.auth, config.serverUrl) : (req: Request, res: Response, next: express.NextFunction) => next();
const server = new McpServer(
{
name: config.name,
version: "1.0.0",
},
{},
);
// Map to keep transports per session id
const sessionTransports = new Map<string, StreamableHTTPServerTransport>();
/**
* Returns an existing transport for the given request/session or creates a new one.
* The returned transport is kept in memory so that subsequent requests in the same
* session share state (tools, prompts, etc.).
*/
function ensureTransport(req: Request, res: Response): StreamableHTTPServerTransport {
let sessionId = (req.headers["mcp-session-id"] as string | undefined) || undefined;
if (sessionId && sessionTransports.has(sessionId)) {
return sessionTransports.get(sessionId)!;
}
// Create a new session / transport
if (!sessionId) {
sessionId = randomUUID();
}
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => sessionId! });
server.connect(transport).catch((err) => console.error("Failed to connect transport", err));
sessionTransports.set(sessionId, transport);
// Echo the session id back so the client can reuse it on next requests
res.setHeader("mcp-session-id", sessionId);
// Clean up when the transport closes (e.g., SSE connection dropped or DELETE session)
(transport as any).on?.("close", () => {
sessionTransports.delete(sessionId!);
});
return transport;
}
// // Initialize dynamic tools manager if configured
// let dynamicToolsManager: DynamicToolsManager | undefined;
// if (config.dynamicTools?.enabled) {
// dynamicToolsManager = new DynamicToolsManager(server, config.dynamicTools);
// dynamicToolsManager.start();
// }
// --- Validation ---
// Before starting, validate that all declared relationships point to existing resources.
// This prevents runtime errors and ensures schema integrity.
const resourceNames = new Set(config.resources.map((r) => r.name));
for (const resource of config.resources) {
const relations = resource.relations;
if (relations) {
for (const fieldName in relations) {
if (!resource.schema.shape[fieldName]) {
throw new Error(`Configuration error in resource '${resource.name}': Relation field '${fieldName}' does not exist in the schema.`);
}
const relation = relations[fieldName as keyof typeof relations];
if (!relation) {
continue;
}
const relatedResourceName = relation.type;
if (!resourceNames.has(relatedResourceName)) {
throw new Error(`Configuration error in resource '${resource.name}': Related resource type '${relatedResourceName}' for field '${fieldName}' is not defined.`);
}
}
}
}
// --- Type Exposure ---
// If enabled, create special read-only resources that expose the JSON schema
// of other resources. This allows clients (and AI models) to discover the data model.
if (config.exposeTypes) {
const resourcesToExpose = config.exposeTypes === true ? config.resources : config.exposeTypes;
for (const resourceConfig of resourcesToExpose) {
const { name: resourceName, schema, relations } = resourceConfig;
const baseJsonSchema = zodToJsonSchema(schema, {
$refStrategy: "none",
});
const jsonSchemaWithRelations = applyRelationsToJsonSchema(baseJsonSchema, schema, relations, config.name);
const related_tools = Object.keys(resourceConfig.methods).map((handlerName) => {
if (handlerName === "list" || handlerName === "search") return `${handlerName}_${resourceName}s`;
return `${handlerName}_${resourceName}`;
});
const resource_template_uri = resourceConfig.get ? `data://${config.name}/${resourceConfig.uri_template}` : undefined;
const typeResource = {
...jsonSchemaWithRelations,
name: resourceName,
description: schema.description,
related_tools,
resource_template_uri,
};
// This is the read-only resource that serves the JSON schema.
const uri = `type://${config.name}/${resourceName}`;
server.resource(`${resourceName}`, uri, async (): Promise<ReadResourceResult> => {
return {
contents: [
{
uri,
mimeType: "application/json",
text: JSON.stringify(typeResource, null, 2),
},
],
};
});
}
}
// --- Resource and Tool Registration ---
// Loop through each user-defined resource and create the corresponding
// MCP resources and tools.
for (const resource of config.resources) {
const { name, get, methods, uri_template, relations } = resource;
/** Generates the canonical data URI for a resource instance. */
const generateUri = (item: { id: string }): string => {
return `data://${config.name}/${uri_template.replace("{id}", item.id)}`;
};
if (get) {
const full_uri_template = `data://${config.name}/${uri_template}`;
const template = new ResourceTemplate(full_uri_template, { list: undefined });
server.resource(name, template, async (uri, { id }): Promise<ReadResourceResult> => {
const item = await withRetry(() => get({ id: id as string }), config.retry);
if (!item) {
return { contents: [] };
}
return {
contents: [
{
uri: uri.href,
mimeType: "application/json",
text: JSON.stringify(item),
},
],
};
});
}
// --- Workaround for schema generation with relations ---
// The MCP SDK's `server.tool()` method builds the final JSON schema for the tool's input
// internally. To inject our custom `$ref` properties for relations, we perform a trick:
// 1. Create a temporary, isolated MCP server instance.
// 2. Register a temporary tool on it with the desired input shape.
// 3. This lets us access the generated `inputSchema` from the temporary tool.
// 4. We then apply our relationship modifications to this schema.
// 5. Finally, we create the *real* tool on our main server and then overwrite its
// (private) `inputSchema` property with our modified version.
// This preserves the SDK's internal argument parsing while giving us the schema we need.
if (methods) {
for (const methodName in methods) {
const method = methods[methodName];
const { description, inputSchema, handler, returnsResourceList } = method!;
const toolName = methodName === "list" || methodName === "search" ? `${methodName}_${name}s` : `${methodName}_${name}`;
const tempServer = new McpServer({ name: "temp", version: "1.0.0" }, {});
const tempTool = tempServer.tool("temp", "temp", inputSchema.shape, async () => ({ content: [] }));
const inputSchemaWithRelations = applyRelationsToJsonSchema(tempTool.inputSchema, inputSchema, relations, config.name);
/**
* Tool handler wrapper: passes user from request context as second parameter.
* @param args - The tool arguments (validated against input schema).
*/
const wrappedHandler = async (args: z.infer<typeof inputSchema>): Promise<CallToolResult> => {
// Get user data from request context (set by auth middleware)
const user: UserProfile | JWTPayload | undefined = getCurrentUser();
console.log(`\nπ§ HANDLER DEBUG - ${toolName}`);
console.log("π Arguments:", JSON.stringify(args, null, 2));
console.log("π€ User from context:", user ? {
id: user.id || (user as any).sub,
username: user.username || "N/A",
email: user.email || "N/A"
} : "β No user in context");
console.log("π§".repeat(30));
const result = await withRetry(() => handler(args, user), config.retry);
// Validate the handler's output against the defined schema.
const validatedResult = method!.outputSchema.parse(result);
// For methods that return a list of resources, add the canonical URI to each item.
if (returnsResourceList && Array.isArray(validatedResult)) {
const resultsWithUris = validatedResult.map((item) => ({
...item,
uri: generateUri(item),
}));
return { content: [{ type: "text" as const, text: JSON.stringify(resultsWithUris) }] };
}
// For methods that return a single resource, add the canonical URI.
if ((methodName === "create" || methodName === "update") && typeof validatedResult === "object" && validatedResult !== null && "id" in validatedResult) {
const resultWithUri = { ...validatedResult, uri: generateUri(validatedResult as { id: string }) };
return { content: [{ type: "text" as const, text: JSON.stringify(resultWithUri) }] };
}
return { content: [{ type: "text" as const, text: JSON.stringify(validatedResult) }] };
};
// Register the tool with the wrapped handler
const registeredTool = server.tool(toolName, description, inputSchema.shape, wrappedHandler);
// Overwrite the schema with our modified version.
(registeredTool as any).inputSchema = inputSchemaWithRelations;
}
}
}
// --- Server Metadata Exposure ---
// If server metadata is provided, expose it as a read-only resource
if (config.serverMetadata) {
const metadataResource = {
...config.serverMetadata,
resources: config.resources.map((r) => ({
name: r.name,
description: r.schema.description,
methods: Object.keys(r.methods),
})),
capabilities: {
authentication: !!config.auth,
rateLimiting: !!config.rateLimit,
retries: !!config.retry,
streaming: true, // MCP supports streaming
},
};
server.resource("server_metadata", `metadata://${config.name}/server`, async (): Promise<ReadResourceResult> => {
return {
contents: [
{
uri: `metadata://${config.name}/server`,
mimeType: "application/json",
text: JSON.stringify(metadataResource, null, 2),
},
],
};
});
}
// --- Server Transport ---
// Set up the Express endpoint to handle all incoming MCP requests.
// Handle all HTTP methods for the MCP endpoint
app.all("/", (req: Request, res: Response, next: express.NextFunction) => {
// Allow POST, GET, and DELETE requests for MCP operations
if (!["POST", "GET", "DELETE"].includes(req.method)) {
return res.status(405).json({
error: "Method not allowed",
message: "Only POST, GET, and DELETE requests are allowed for MCP operations",
});
}
next();
});
app.post("/", authMiddleware, async (req: Request, res: Response) => {
console.log("\n" + "π΅".repeat(20));
console.log("π¨ MCP POST REQUEST RECEIVED");
console.log("π΅".repeat(20));
console.log("π Request Details:");
console.log(" URL:", req.url);
console.log(" Method:", req.method);
console.log(" Content-Type:", req.headers['content-type']);
console.log(" User-Agent:", req.headers['user-agent']);
console.log("\nπ Authentication Status:");
if ((req as any).auth) {
console.log(" β
Authenticated");
console.log(" π€ User:", (req as any).auth.id || (req as any).auth.sub || "Unknown");
console.log(" π Auth Data:", JSON.stringify((req as any).auth, null, 2));
} else {
console.log(" β Not authenticated (this should not happen if auth is enabled!)");
}
console.log("\nπ¦ Request Body:");
console.log(" ", JSON.stringify(req.body, null, 2));
console.log("\nπ Forwarding to MCP transport with user context...");
console.log("π΅".repeat(20) + "\n");
const transport = ensureTransport(req, res);
// Run the transport handling within the request context
const sessionId = (req.headers["mcp-session-id"] as string) || "unknown";
const context: RequestContext = {
user: (req as any).auth,
sessionId
};
await requestContext.run(context, async () => {
console.log("π Running transport within request context");
console.log(" Context user:", context.user?.id || context.user?.sub || "No user");
await transport.handleRequest(req, res, req.body);
});
});
// Use conditional auth for GET/SSE β once a session is established we donβt need to re-authenticate on every poll
app.get("/", (req: Request, res: Response, next: express.NextFunction) => {
console.log("\n" + "π’".repeat(20));
console.log("π‘ MCP GET REQUEST RECEIVED");
console.log("π’".repeat(20));
console.log("π Request Details:");
console.log(" URL:", req.url);
console.log(" Method:", req.method);
console.log(" Accept:", req.headers.accept);
console.log(" User-Agent:", req.headers['user-agent']);
const sessionId = (req.headers["mcp-session-id"] as string | undefined) || undefined;
console.log("\nπ Session Management:");
console.log(" MCP-Session-ID:", sessionId || "β MISSING");
console.log(" Has existing transport:", sessionId ? sessionTransports.has(sessionId) : false);
if (sessionId && sessionTransports.has(sessionId)) {
console.log(" β
Using existing session, skipping auth");
console.log("π’".repeat(20) + "\n");
return next(); // skip auth, session already validated
} else {
console.log(" π No existing session, applying auth middleware");
console.log("π’".repeat(20) + "\n");
return authMiddleware(req, res, next);
}
}, async (req: Request, res: Response) => {
console.log("\nπ Processing GET request after auth...");
console.log("π― Accept headers:", req.headers.accept);
if (!req.accepts(["text/event-stream", "application/json"])) {
console.log("β Rejecting request - unsupported Accept header");
return res.status(406).json({ error: "Not Acceptable: Client must accept text/event-stream or application/json" });
}
console.log("β
Accept header valid, forwarding to transport");
const transport = ensureTransport(req, res);
// Run within request context if we have auth data
const sessionId = (req.headers["mcp-session-id"] as string) || "unknown";
const authData = (req as any).auth;
if (authData) {
const context: RequestContext = {
user: authData,
sessionId
};
await requestContext.run(context, async () => {
console.log("π Running GET transport within request context");
await transport.handleRequest(req, res);
});
} else {
// No auth data (probably using existing session)
console.log("π Running GET transport without auth context (existing session)");
await transport.handleRequest(req, res);
}
});
app.delete("/", (req: Request, res: Response, next: express.NextFunction) => {
const sessionId = (req.headers["mcp-session-id"] as string | undefined) || undefined;
if (sessionId && sessionTransports.has(sessionId)) {
return next();
}
return authMiddleware(req, res, next);
}, async (req: Request, res: Response) => {
console.log("Received MCP DELETE request (session termination)");
const transport = ensureTransport(req, res);
await transport.handleRequest(req, res);
// Explicitly close session after handling
transport.close();
});
// Add cleanup for dynamic tools manager when the app is closed
function gracefulShutdown(signal: string) {
console.log(`\n${signal} received. Shutting down MCP server...`);
// if (dynamicToolsManager) {
// dynamicToolsManager.stop();
// }
// Close all transports
for (const transport of sessionTransports.values()) {
transport.close();
}
server.close();
process.exit(0);
}
process.once("SIGINT", () => gracefulShutdown("SIGINT"));
process.once("SIGTERM", () => gracefulShutdown("SIGTERM"));
return app;
}