Skip to main content
Glama
index.ts18.9 kB
#!/usr/bin/env node import { randomUUID } from "node:crypto"; import fs from "node:fs"; import { createServer } from "node:http"; import os from "node:os"; import nodePath from "node:path"; import { Server } from "@modelcontextprotocol/sdk/server/index.js"; import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js"; import Busboy from "busboy"; import { setupRequestHandlers } from "./requestHandler.js"; import { clearResourcesForServer, configureResourceManager, getFileResourceById } from "./resourceManager.js"; import { setUserAgentConfig } from "./toolHandler.js"; import { createToolDefinitions } from "./tools.js"; import { clearUploadsForSession, ensureUploadsDir, registerUploadedFile, setUploadEndpointUrl, } from "./uploadManager.js"; const DEFAULT_PORT = 8000; const DEFAULT_PATH = "/mcp"; const DEFAULT_RESOURCE_TTL_SECONDS = 600; const DEFAULT_HOSTNAME = os.hostname(); export interface CliOptions { http: boolean; port: number; path: string; resourceTtlSeconds: number; hostName: string; insecure: boolean; listen: string; staticUserAgent: boolean; } export function normalizeHttpPath(value: string): string { const trimmed = value.trim(); if (!trimmed) { throw new Error("HTTP path cannot be empty"); } const withLeadingSlash = trimmed.startsWith("/") ? trimmed : `/${trimmed}`; return withLeadingSlash.endsWith("/") && withLeadingSlash !== "/" ? withLeadingSlash.slice(0, -1) : withLeadingSlash; } export function parseCliOptions(args: string[]): CliOptions { const options: CliOptions = { http: false, port: DEFAULT_PORT, path: DEFAULT_PATH, resourceTtlSeconds: DEFAULT_RESOURCE_TTL_SECONDS, hostName: DEFAULT_HOSTNAME, insecure: false, listen: "0.0.0.0", staticUserAgent: false, }; const parsePort = (value: string) => { const port = Number.parseInt(value, 10); if (!Number.isFinite(port) || port < 0 || port > 65535) { throw new Error(`Invalid port value: ${value}`); } return port; }; const parseTtl = (value: string) => { const seconds = Number.parseInt(value, 10); if (!Number.isFinite(seconds) || seconds <= 0) { throw new Error(`Invalid resource TTL: ${value}`); } return seconds; }; const parseHost = (value: string) => { const trimmed = value.trim(); if (!trimmed) { throw new Error("Host name cannot be empty"); } return trimmed; }; for (let i = 0; i < args.length; i += 1) { const arg = args[i]; if (arg === "--http") { options.http = true; } else if (arg === "--port") { const value = args[i + 1]; if (!value) { throw new Error("Missing value for --port"); } options.port = parsePort(value); i += 1; } else if (arg.startsWith("--port=")) { options.port = parsePort(arg.split("=")[1] ?? ""); } else if (arg === "--path") { const value = args[i + 1]; if (!value) { throw new Error("Missing value for --path"); } options.path = normalizeHttpPath(value); i += 1; } else if (arg.startsWith("--path=")) { options.path = normalizeHttpPath(arg.split("=")[1] ?? ""); } else if (arg === "--resource-ttl") { const value = args[i + 1]; if (!value) { throw new Error("Missing value for --resource-ttl"); } options.resourceTtlSeconds = parseTtl(value); i += 1; } else if (arg.startsWith("--resource-ttl=")) { options.resourceTtlSeconds = parseTtl(arg.split("=")[1] ?? ""); } else if (arg === "--host-name") { const value = args[i + 1]; if (!value) { throw new Error("Missing value for --host-name"); } options.hostName = parseHost(value); i += 1; } else if (arg.startsWith("--host-name=")) { options.hostName = parseHost(arg.split("=")[1] ?? ""); } else if (arg === "--insecure") { options.insecure = true; } else if (arg === "--static-user-agent") { options.staticUserAgent = true; } else if (arg === "--listen") { const value = args[i + 1]; if (!value) { throw new Error("Missing value for --listen"); } options.listen = value; i += 1; } else if (arg.startsWith("--listen=")) { options.listen = arg.split("=")[1] ?? "0.0.0.0"; } } return options; } export function createMcpServer(options?: { httpMode?: boolean; uploadEndpoint?: string }): Server { const server = new Server( { name: "playwright-mcp", version: "1.0.6", }, { capabilities: { resources: { listChanged: true, }, tools: {}, }, }, ); const tools = createToolDefinitions({ httpMode: options?.httpMode ?? false, uploadEndpoint: options?.uploadEndpoint, }); setupRequestHandlers(server, tools); return server; } function registerShutdown(cleanup?: () => Promise<void>) { let isShuttingDown = false; const shutdown = async () => { if (isShuttingDown) { return; } isShuttingDown = true; console.log("Shutdown signal received"); if (cleanup) { try { await cleanup(); } catch (error) { console.error("Error during shutdown:", error); } } process.exit(0); }; process.on("SIGINT", shutdown); process.on("SIGTERM", shutdown); process.on("uncaughtException", (err) => { console.error("Uncaught Exception:", err); }); } async function runStdioServer() { configureResourceManager({ enabled: false }); const server = createMcpServer({ httpMode: false }); registerShutdown(() => server.close()); const transport = new StdioServerTransport(); await server.connect(transport); } export async function startStreamableHttpServer({ port, path, resourceTtlSeconds = DEFAULT_RESOURCE_TTL_SECONDS, hostName = DEFAULT_HOSTNAME, insecure = false, listen = "0.0.0.0", }: { port: number; path: string; resourceTtlSeconds?: number; hostName?: string; insecure?: boolean; listen?: string; }) { const sessionByServer = new Map<Server, string>(); const normalizedPath = normalizeHttpPath(path); configureResourceManager({ enabled: true, ttlSeconds: resourceTtlSeconds, buildResourceUri: ({ server, id, filename }) => { const sessionId = sessionByServer.get(server); if (!sessionId) return undefined; const scheme = insecure ? "http" : "https"; const portPart = `:${port}`; const sanitizedFile = encodeURIComponent(filename); return `${scheme}://${hostName}${portPart}${normalizedPath}/resources/${sessionId}/${id}/${sanitizedFile}`; }, getSessionId: (server) => sessionByServer.get(server), }); const sessions: Record<string, { transport: StreamableHTTPServerTransport; server: Server }> = {}; const uploadsPath = `${normalizedPath}/uploads`; const uploadEndpointBase = `${insecure ? "http" : "https"}://${hostName}:${port}${uploadsPath}`; setUploadEndpointUrl(uploadEndpointBase); const server = createServer(async (req, res) => { try { const url = req.url ? new URL(req.url, "http://localhost") : null; const method = req.method ?? "GET"; const downloadPrefix = `${normalizedPath}/resources/`; if (url?.pathname.startsWith(downloadPrefix)) { const parts = url.pathname.slice(downloadPrefix.length).split("/"); if (parts.length < 2) { res.writeHead(400).end("Invalid download path"); return; } const [sessionId, resourceId] = parts; const session = sessions[sessionId]; if (!session) { res.writeHead(404).end("Not Found"); return; } const fileResource = await getFileResourceById(session.server, resourceId); if (!fileResource) { res.writeHead(404).end("Not Found"); return; } try { const stat = await fs.promises.stat(fileResource.filePath); res.writeHead(200, { "content-type": fileResource.mimeType ?? "application/octet-stream", "content-length": stat.size, "content-disposition": `inline; filename="${nodePath.basename(fileResource.name)}"`, }); fs.createReadStream(fileResource.filePath).pipe(res); } catch (error) { console.error("Failed to serve download:", error); res.writeHead(500).end("Failed to serve file"); } return; } const uploadPathPrefix = `${uploadsPath}/`; const isUploadPath = (url?.pathname === uploadsPath || (url?.pathname ?? "").startsWith(uploadPathPrefix)) && method === "POST"; if (isUploadPath) { const sessionIdFromPath = url?.pathname?.startsWith(uploadPathPrefix) ? url.pathname.slice(uploadPathPrefix.length).split("/")[0] : undefined; const sessionIdHeader = req.headers["x-mcp-session-id"] ?? req.headers["mcp-session-id"]; const sessionIdHeaderValue = Array.isArray(sessionIdHeader) ? sessionIdHeader[0] : sessionIdHeader; const sessionId = sessionIdFromPath || sessionIdHeaderValue; const safeEnd = (status: number, message: string) => { if (res.writableEnded) return; res.writeHead(status).end(message); }; if (!sessionId || !sessions[sessionId]) { safeEnd(400, "Invalid or missing session ID"); return; } const contentType = String(req.headers["content-type"] ?? "").toLowerCase(); if (!contentType.includes("multipart/form-data")) { safeEnd( 415, 'Invalid content-type; expected multipart/form-data with a boundary. Example: curl -F "file=@path" <upload-url>', ); return; } if (!contentType.includes("boundary=")) { safeEnd( 400, 'Missing multipart boundary. Ensure you send multipart/form-data with a boundary (e.g., curl -F "file=@path" <upload-url>).', ); return; } const sessionDir = await ensureUploadsDir(sessionId); let storedPath: string | null = null; let originalName = "upload.bin"; let mimeType: string | undefined; let size = 0; let errored = false; try { const busboy = Busboy({ headers: req.headers }); await new Promise<void>((resolve, reject) => { busboy.on("file", (_fieldname, file, info) => { const safeName = info.filename ? nodePath.basename(info.filename) : "upload.bin"; originalName = safeName; mimeType = info.mimeType; const targetPath = nodePath.join(sessionDir, `${randomUUID()}-${safeName}`); storedPath = targetPath; const writeStream = fs.createWriteStream(targetPath); file.on("data", (data) => { size += data.length; writeStream.write(data); }); file.on("end", () => { writeStream.end(); }); file.on("error", (err) => { errored = true; writeStream.destroy(err); reject(err); }); writeStream.on("error", (err) => { errored = true; reject(err); }); }); busboy.on("finish", () => { if (!storedPath || errored) { reject(new Error("Upload failed")); return; } resolve(); }); busboy.on("error", (err) => reject(err)); req.on("aborted", () => reject(new Error("Upload aborted"))); req.pipe(busboy); }); } catch (error) { const message = error instanceof Error && error.message.toLowerCase().includes("boundary not found") ? 'Upload failed: multipart boundary not found. Use POST multipart/form-data with a boundary (e.g., curl -F "file=@path" <upload-url>).' : 'Upload failed. Ensure you POST multipart/form-data with field name "file".'; console.error("Upload handling failed:", error); safeEnd(400, message); return; } if (!storedPath) { safeEnd(400, "No file uploaded"); return; } const registered = registerUploadedFile({ sessionId, filePath: storedPath, name: originalName, mimeType, size, }); if (res.writableEnded) return; res.writeHead(200, { "content-type": "application/json" }).end( JSON.stringify({ resourceUri: registered.uri, name: registered.name, mimeType: registered.mimeType, size: registered.size, }), ); return; } if (!url || url.pathname !== normalizedPath) { res.writeHead(404).end("Not Found"); return; } const sendJsonError = (status: number, message: string) => { res.writeHead(status, { "content-type": "application/json" }).end( JSON.stringify({ jsonrpc: "2.0", error: { code: -32000, message }, id: null, }), ); }; const getSession = () => { const sessionId = req.headers["mcp-session-id"] === undefined ? undefined : String(req.headers["mcp-session-id"]); return sessionId ? sessions[sessionId] : undefined; }; if (method === "POST") { const chunks: Buffer[] = []; for await (const chunk of req) { chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : Buffer.from(chunk)); } const rawBody = Buffer.concat(chunks).toString("utf8"); let parsedBody: unknown; if (rawBody) { try { parsedBody = JSON.parse(rawBody); } catch (_error) { sendJsonError(400, "Invalid JSON body"); return; } } const existingSession = getSession(); if (existingSession) { await existingSession.transport.handleRequest(req, res, parsedBody); return; } if (!parsedBody || !isInitializeRequest(parsedBody)) { sendJsonError(400, "Bad Request: No valid session ID provided"); return; } const mcpServer = createMcpServer({ httpMode: true, uploadEndpoint: uploadEndpointBase, }); let transport: StreamableHTTPServerTransport | null = null; transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: (sessionId: string) => { if (transport) { sessions[sessionId] = { server: mcpServer, transport }; sessionByServer.set(mcpServer, sessionId); } }, }); if (transport.sessionId) { sessions[transport.sessionId] = { server: mcpServer, transport }; sessionByServer.set(mcpServer, transport.sessionId); } let transportClosed = false; transport.onclose = () => { if (transportClosed) return; transportClosed = true; const sessionId = transport?.sessionId; if (sessionId && sessions[sessionId]) { delete sessions[sessionId]; } mcpServer.close().catch((error) => console.error("Error closing MCP server:", error)); clearResourcesForServer(mcpServer); if (sessionId) { clearUploadsForSession(sessionId).catch(() => {}); } sessionByServer.delete(mcpServer); }; await mcpServer.connect(transport); await transport.handleRequest(req, res, parsedBody); return; } if (method === "GET" || method === "DELETE") { const session = getSession(); if (!session) { res.writeHead(400).end("Invalid or missing session ID"); return; } await session.transport.handleRequest(req, res); if (method === "DELETE" && session.transport.sessionId) { clearResourcesForServer(session.server); clearUploadsForSession(session.transport.sessionId).catch(() => {}); delete sessions[session.transport.sessionId]; sessionByServer.delete(session.server); } return; } res.writeHead(405).end("Method Not Allowed"); } catch (error) { console.error("Error handling Streamable HTTP request:", error); if (!res.headersSent) { res.writeHead(500, { "content-type": "application/json" }).end( JSON.stringify({ jsonrpc: "2.0", error: { code: -32603, message: "Internal server error" }, id: null, }), ); } } }); await new Promise<void>((resolve, reject) => { server.once("error", reject); server.listen(port, listen, resolve); }); const address = server.address(); const resolvedPort = typeof address === "object" && address !== null ? address.port : port; console.log(`MCP Streamable HTTP server listening on http://${listen}:${resolvedPort}${normalizedPath}`); const close = async () => { const sessionIds = Object.keys(sessions); for (const sessionId of sessionIds) { const session = sessions[sessionId]; try { await session.server.close(); } catch (error) { console.error(`Error closing session ${sessionId}:`, error); } await clearUploadsForSession(sessionId).catch(() => {}); delete sessions[sessionId]; } await new Promise<void>((resolve, reject) => { server.close((err) => { if (err) { reject(err); return; } resolve(); }); }); }; registerShutdown(close); return { server, close }; } async function runServer() { const options = parseCliOptions(process.argv.slice(2)); if (options.http) { setUserAgentConfig({ staticUserAgent: options.staticUserAgent }); await startStreamableHttpServer({ port: options.port, path: options.path, hostName: options.hostName, insecure: options.insecure, resourceTtlSeconds: options.resourceTtlSeconds, listen: options.listen, }); return; } setUserAgentConfig({ staticUserAgent: options.staticUserAgent }); await runStdioServer(); } const isDirectRun = (process.argv[1] ?? "").endsWith("index.js"); if (isDirectRun) { runServer().catch((error) => { console.error("Fatal error in main():", error); 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/aakashH242/mcp-playwright'

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