Skip to main content
Glama
server-http.ts18.7 kB
import { randomUUID } from "node:crypto"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import { ShortcutClient } from "@shortcut/client"; import express, { type NextFunction, type Request, type Response } from "express"; import pino from "pino"; import { ShortcutClientWrapper } from "@/client/shortcut"; import { CustomMcpServer } from "./mcp/CustomMcpServer"; import { DocumentTools } from "./tools/documents"; import { EpicTools } from "./tools/epics"; import { IterationTools } from "./tools/iterations"; import { ObjectiveTools } from "./tools/objectives"; import { StoryTools } from "./tools/stories"; import { TeamTools } from "./tools/teams"; import { UserTools } from "./tools/user"; import { WorkflowTools } from "./tools/workflows"; // ============================================================================ // Constants // ============================================================================ const DEFAULT_PORT = 9292; const BEARER_PREFIX = "Bearer "; const SESSION_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes const HEADERS = { AUTHORIZATION: "authorization", X_SHORTCUT_API_TOKEN: "x-shortcut-api-token", MCP_SESSION_ID: "mcp-session-id", LAST_EVENT_ID: "last-event-id", } as const; const JSON_RPC_ERRORS = { UNAUTHORIZED: { code: -32000, message: "Unauthorized" }, BAD_REQUEST: { code: -32000, message: "Bad Request" }, SESSION_NOT_FOUND: { code: -32001, message: "Session not found" }, INVALID_TOKEN: { code: -32002, message: "Invalid API token" }, INTERNAL_ERROR: { code: -32603, message: "Internal server error" }, } as const; // ============================================================================ // Logger // ============================================================================ const logger = pino({ level: process.env.LOG_LEVEL || "info", transport: process.env.NODE_ENV !== "production" ? { target: "pino-pretty", options: { colorize: true, translateTime: "HH:MM:ss", ignore: "pid,hostname", }, } : undefined, }); // ============================================================================ // Configuration // ============================================================================ interface ServerConfig { port: number; isReadonly: boolean; enabledTools: string[]; sessionTimeoutMs: number; } function loadConfig(): ServerConfig { let isReadonly = process.env.SHORTCUT_READONLY !== "false"; let enabledTools = parseToolsList(process.env.SHORTCUT_TOOLS || ""); // Parse command line arguments if (process.argv.length >= 3) { process.argv .slice(2) .map((arg) => arg.split("=")) .forEach(([name, value]) => { if (name === "SHORTCUT_READONLY") isReadonly = value !== "false"; if (name === "SHORTCUT_TOOLS") enabledTools = parseToolsList(value); }); } return { port: Number.parseInt(process.env.PORT || String(DEFAULT_PORT), 10), isReadonly, enabledTools, sessionTimeoutMs: SESSION_TIMEOUT_MS, }; } function parseToolsList(toolsStr: string): string[] { return toolsStr .split(",") .map((tool) => tool.trim()) .filter(Boolean); } // ============================================================================ // Session Management // ============================================================================ interface SessionData { transport: StreamableHTTPServerTransport; apiToken: string; // Store hashed version in production createdAt: Date; lastAccessedAt: Date; } class SessionManager { private sessions: Map<string, SessionData> = new Map(); private cleanupInterval: ReturnType<typeof setInterval> | null = null; constructor(private timeoutMs: number) { // Start periodic cleanup of stale sessions this.cleanupInterval = setInterval(() => this.cleanupStaleSessions(), 60000); // Check every minute } has(sessionId: string): boolean { return this.sessions.has(sessionId); } get(sessionId: string): SessionData | undefined { const session = this.sessions.get(sessionId); if (session) { session.lastAccessedAt = new Date(); } return session; } add(sessionId: string, transport: StreamableHTTPServerTransport, apiToken: string): void { this.sessions.set(sessionId, { transport, apiToken, createdAt: new Date(), lastAccessedAt: new Date(), }); logger.info({ sessionId }, "Session initialized"); } remove(sessionId: string): void { const session = this.sessions.get(sessionId); if (session) { this.sessions.delete(sessionId); logger.info({ sessionId }, "Session removed"); } } validateToken(sessionId: string, providedToken: string): boolean { const session = this.sessions.get(sessionId); if (!session) { return false; } // Compare hashed tokens return session.apiToken === providedToken; } private cleanupStaleSessions(): void { const now = Date.now(); const staleSessionIds: string[] = []; for (const [sessionId, session] of this.sessions.entries()) { const timeSinceLastAccess = now - session.lastAccessedAt.getTime(); if (timeSinceLastAccess > this.timeoutMs) { staleSessionIds.push(sessionId); } } if (staleSessionIds.length > 0) { logger.info({ count: staleSessionIds.length }, "Cleaning up stale sessions"); for (const sessionId of staleSessionIds) { const session = this.sessions.get(sessionId); if (session) { session.transport.close().catch((error) => { logger.error({ sessionId, error }, "Error closing stale transport"); }); this.remove(sessionId); } } } } async closeAll(): Promise<void> { logger.info("Shutting down server..."); if (this.cleanupInterval) { clearInterval(this.cleanupInterval); } const closePromises: Promise<void>[] = []; for (const [sessionId, session] of this.sessions.entries()) { logger.debug({ sessionId }, "Closing session"); closePromises.push( session.transport.close().catch((error) => { logger.error({ sessionId, error }, "Error closing transport"); }), ); this.remove(sessionId); } await Promise.all(closePromises); logger.info("Server shutdown complete"); } } // ============================================================================ // Authentication & Validation // ============================================================================ function extractApiToken(req: Request): string | null { // Try Authorization header first (Bearer token) const authHeader = req.headers[HEADERS.AUTHORIZATION]; if (authHeader?.startsWith(BEARER_PREFIX)) { return authHeader.slice(BEARER_PREFIX.length); } // Try custom header const customHeader = req.headers[HEADERS.X_SHORTCUT_API_TOKEN]; if (typeof customHeader === "string") { return customHeader; } return null; } /** * Validates the API token by attempting to fetch current user info. * This ensures the token is valid before creating a session. */ async function validateApiToken(token: string): Promise<boolean> { try { const client = new ShortcutClient(token); // Validate by attempting to get current user info await client.getCurrentMemberInfo(); return true; } catch (error) { logger.debug( { error: error instanceof Error ? error.message : error }, "API token validation failed", ); return false; } } // ============================================================================ // Error Responses // ============================================================================ interface JsonRpcError { jsonrpc: "2.0"; error: { code: number; message: string; }; id: unknown; } function sendUnauthorizedError(res: Response, message?: string): void { res.status(401).json({ jsonrpc: "2.0", error: { ...JSON_RPC_ERRORS.UNAUTHORIZED, message: message || "API token required. Provide via Authorization: Bearer <token> or X-Shortcut-API-Token: <token>", }, id: null, } satisfies JsonRpcError); } function sendInvalidTokenError(res: Response, requestId?: unknown): void { res.status(401).json({ jsonrpc: "2.0", error: { ...JSON_RPC_ERRORS.INVALID_TOKEN, message: "Invalid or expired API token. Please check your credentials.", }, id: requestId || null, } satisfies JsonRpcError); } function sendSessionNotFoundError(res: Response, sessionId: string, requestId?: unknown): void { logger.warn({ sessionId }, "Session not found - may have expired or server restarted"); res.status(404).json({ jsonrpc: "2.0", error: { ...JSON_RPC_ERRORS.SESSION_NOT_FOUND, message: "Session not found or expired. Please re-initialize the connection.", }, id: requestId || null, } satisfies JsonRpcError); } function sendBadRequestError(res: Response, message: string, requestId?: unknown): void { res.status(400).json({ jsonrpc: "2.0", error: { ...JSON_RPC_ERRORS.BAD_REQUEST, message, }, id: requestId || null, } satisfies JsonRpcError); } function sendInternalError(res: Response, requestId?: unknown): void { if (!res.headersSent) { res.status(500).json({ jsonrpc: "2.0", error: JSON_RPC_ERRORS.INTERNAL_ERROR, id: requestId || null, } satisfies JsonRpcError); } } // ============================================================================ // MCP Server Creation // ============================================================================ function createServerInstance(apiToken: string, config: ServerConfig): CustomMcpServer { const server = new CustomMcpServer({ readonly: config.isReadonly, tools: config.enabledTools, }); const client = new ShortcutClientWrapper(new ShortcutClient(apiToken)); // The order these are created impacts the order they are listed to the LLM // Most important tools should be at the top UserTools.create(client, server); StoryTools.create(client, server); IterationTools.create(client, server); EpicTools.create(client, server); ObjectiveTools.create(client, server); TeamTools.create(client, server); WorkflowTools.create(client, server); DocumentTools.create(client, server); return server; } // ============================================================================ // Transport Management // ============================================================================ async function createTransport( apiToken: string, config: ServerConfig, sessionManager: SessionManager, ): Promise<StreamableHTTPServerTransport> { let transport: StreamableHTTPServerTransport | null = null; transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sid): void => { if (transport) { sessionManager.add(sid, transport, apiToken); } }, }); // Set up cleanup on close transport.onclose = () => { if (transport) { const sid = transport.sessionId; if (sid && sessionManager.has(sid)) { sessionManager.remove(sid); } } }; // Create server instance with the API token and connect const server = createServerInstance(apiToken, config); await server.connect(transport); return transport; } // ============================================================================ // Request Handlers // ============================================================================ async function handleMcpPost( req: Request, res: Response, sessionManager: SessionManager, config: ServerConfig, ): Promise<void> { const sessionId = req.headers[HEADERS.MCP_SESSION_ID] as string | undefined; const apiToken = extractApiToken(req); const requestId = req.body?.id; const reqLogger = logger.child({ sessionId: sessionId || "new", method: "POST" }); reqLogger.debug({ hasToken: !!apiToken }, "Received POST request"); try { // Scenario 1: Existing session if (sessionId && sessionManager.has(sessionId)) { if (!apiToken) { sendUnauthorizedError(res); return; } // Validate that the provided token matches the session's token if (!sessionManager.validateToken(sessionId, apiToken)) { reqLogger.warn("Token mismatch for session"); sendUnauthorizedError(res, "API token does not match the session"); return; } const session = sessionManager.get(sessionId)!; await session.transport.handleRequest(req, res, req.body); return; } // Scenario 2: Initialization request if (isInitializeRequest(req.body)) { if (!apiToken) { sendUnauthorizedError(res); return; } // Validate the API token before creating a session reqLogger.info("Validating API token"); const isValid = await validateApiToken(apiToken); if (!isValid) { reqLogger.warn("API token validation failed"); sendInvalidTokenError(res, requestId); return; } reqLogger.info("API token validated, creating session"); const transport = await createTransport(apiToken, config, sessionManager); await transport.handleRequest(req, res, req.body); return; } // Scenario 3: Stale session if (sessionId && !sessionManager.has(sessionId)) { sendSessionNotFoundError(res, sessionId, requestId); return; } // Scenario 4: Missing session ID sendBadRequestError(res, "No session ID provided for non-initialization request", requestId); } catch (error) { reqLogger.error({ error }, "Error handling MCP POST request"); sendInternalError(res, requestId); } } async function handleMcpGet( req: Request, res: Response, sessionManager: SessionManager, ): Promise<void> { const sessionId = req.headers[HEADERS.MCP_SESSION_ID] as string | undefined; const apiToken = extractApiToken(req); const reqLogger = logger.child({ sessionId, method: "GET" }); if (!sessionId || !sessionManager.has(sessionId)) { res.status(400).send("Invalid or missing session ID"); return; } // Validate token for SSE stream establishment if (!apiToken) { sendUnauthorizedError(res); return; } if (!sessionManager.validateToken(sessionId, apiToken)) { reqLogger.warn("Token mismatch for GET request"); sendUnauthorizedError(res, "API token does not match the session"); return; } const lastEventId = req.headers[HEADERS.LAST_EVENT_ID]; if (lastEventId) { reqLogger.info({ lastEventId }, "Client reconnecting with Last-Event-ID"); } else { reqLogger.info("Establishing SSE stream"); } try { const session = sessionManager.get(sessionId)!; await session.transport.handleRequest(req, res); } catch (error) { reqLogger.error({ error }, "Error handling MCP GET request"); if (!res.headersSent) { res.status(500).send("Internal server error"); } } } async function handleMcpDelete( req: Request, res: Response, sessionManager: SessionManager, ): Promise<void> { const sessionId = req.headers[HEADERS.MCP_SESSION_ID] as string | undefined; const apiToken = extractApiToken(req); const reqLogger = logger.child({ sessionId, method: "DELETE" }); if (!sessionId || !sessionManager.has(sessionId)) { res.status(400).send("Invalid or missing session ID"); return; } // Validate token for session termination if (!apiToken) { sendUnauthorizedError(res); return; } if (!sessionManager.validateToken(sessionId, apiToken)) { reqLogger.warn("Token mismatch for DELETE request"); sendUnauthorizedError(res, "API token does not match the session"); return; } reqLogger.info("Terminating session"); try { const session = sessionManager.get(sessionId)!; await session.transport.handleRequest(req, res); // The session will be removed via the onclose handler } catch (error) { reqLogger.error({ error }, "Error handling session termination"); if (!res.headersSent) { res.status(500).send("Error processing session termination"); } } } // ============================================================================ // Middleware // ============================================================================ function corsMiddleware(req: Request, res: Response, next: NextFunction): void { res.header("Access-Control-Allow-Origin", "*"); res.header("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS"); res.header( "Access-Control-Allow-Headers", "Content-Type, Authorization, X-Shortcut-API-Token, Mcp-Session-Id, Last-Event-Id", ); res.header("Access-Control-Expose-Headers", "Mcp-Session-Id"); if (req.method === "OPTIONS") { res.sendStatus(204); return; } next(); } /** * Request logging middleware for debugging and audit purposes */ function loggingMiddleware(req: Request, _res: Response, next: NextFunction): void { const sessionId = req.headers[HEADERS.MCP_SESSION_ID]; const hasToken = !!extractApiToken(req); logger.debug( { method: req.method, path: req.path, sessionId: sessionId || "none", hasToken, }, "Incoming request", ); next(); } // ============================================================================ // Server Setup // ============================================================================ async function startServer() { const config = loadConfig(); const sessionManager = new SessionManager(config.sessionTimeoutMs); const app = express(); // Middleware app.use(express.json()); app.use(corsMiddleware); app.use(loggingMiddleware); // Routes app.get("/health", (_req: Request, res: Response) => { res.json({ status: "ok", service: "shortcut-mcp-server", transport: "streamable-http", timestamp: new Date().toISOString(), version: "2025-06-18", // MCP spec version }); }); app.post("/mcp", (req, res) => handleMcpPost(req, res, sessionManager, config)); app.get("/mcp", (req, res) => handleMcpGet(req, res, sessionManager)); app.delete("/mcp", (req, res) => handleMcpDelete(req, res, sessionManager)); // Start server app.listen(config.port, () => { logger.info( { port: config.port, readonly: config.isReadonly, sessionTTL: `${config.sessionTimeoutMs / 1000 / 60}m`, enabledTools: config.enabledTools.length > 0 ? config.enabledTools : "all", mcpSpec: "2025-06-18", }, "Shortcut MCP Server (Streamable HTTP) started", ); logger.info(`Server URL: http://localhost:${config.port}`); logger.info(`Health check: http://localhost:${config.port}/health`); logger.info(`MCP endpoint: http://localhost:${config.port}/mcp`); }); // Graceful shutdown process.on("SIGINT", async () => { await sessionManager.closeAll(); process.exit(0); }); process.on("SIGTERM", async () => { await sessionManager.closeAll(); process.exit(0); }); } // ============================================================================ // Entry Point // ============================================================================ startServer().catch((error) => { logger.fatal({ error }, "Fatal error starting server"); process.exit(1); });

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/useshortcut/mcp-server-shortcut'

If you have feedback or need assistance with the MCP directory API, please join our Discord server