cloudflare/cloudflare-typescript
Publicmirrored fromhttps://github.com/cloudflare/cloudflare-typescriptAvailable
packages/mcp-server/src/http.ts
227lines · modecode
| 1 | // File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details. |
| 2 | |
| 3 | import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; |
| 4 | import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; |
| 5 | import { ClientOptions } from 'cloudflare'; |
| 6 | import express from 'express'; |
| 7 | import pino from 'pino'; |
| 8 | import pinoHttp from 'pino-http'; |
| 9 | import { getStainlessApiKey, parseClientAuthHeaders } from './auth'; |
| 10 | import { getLogger } from './logger'; |
| 11 | import { McpOptions } from './options'; |
| 12 | import { initMcpServer, newMcpServer } from './server'; |
| 13 | |
| 14 | const newServer = async ({ |
| 15 | clientOptions, |
| 16 | mcpOptions, |
| 17 | req, |
| 18 | res, |
| 19 | }: { |
| 20 | clientOptions: ClientOptions; |
| 21 | mcpOptions: McpOptions; |
| 22 | req: express.Request; |
| 23 | res: express.Response; |
| 24 | }): Promise<McpServer | null> => { |
| 25 | const stainlessApiKey = getStainlessApiKey(req, mcpOptions); |
| 26 | const customInstructionsPath = mcpOptions.customInstructionsPath; |
| 27 | const server = await newMcpServer({ stainlessApiKey, customInstructionsPath }); |
| 28 | |
| 29 | const authOptions = parseClientAuthHeaders(req, false); |
| 30 | |
| 31 | let upstreamClientEnvs: Record<string, string> | undefined; |
| 32 | const clientEnvsHeader = req.headers['x-stainless-mcp-client-envs']; |
| 33 | if (typeof clientEnvsHeader === 'string') { |
| 34 | try { |
| 35 | const parsed = JSON.parse(clientEnvsHeader); |
| 36 | if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { |
| 37 | upstreamClientEnvs = parsed; |
| 38 | } |
| 39 | } catch { |
| 40 | // Ignore malformed header |
| 41 | } |
| 42 | } |
| 43 | |
| 44 | // Parse x-stainless-mcp-client-permissions header to override permission options |
| 45 | // |
| 46 | // Note: Permissions are best-effort and intended to prevent clients from doing unexpected things; |
| 47 | // they're not a hard security boundary, so we allow arbitrary, client-driven overrides. |
| 48 | // |
| 49 | // See the Stainless MCP documentation for more details. |
| 50 | let effectiveMcpOptions = mcpOptions; |
| 51 | const clientPermissionsHeader = req.headers['x-stainless-mcp-client-permissions']; |
| 52 | if (typeof clientPermissionsHeader === 'string') { |
| 53 | try { |
| 54 | const parsed = JSON.parse(clientPermissionsHeader); |
| 55 | if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { |
| 56 | effectiveMcpOptions = { |
| 57 | ...mcpOptions, |
| 58 | ...(typeof parsed.allow_http_gets === 'boolean' && { codeAllowHttpGets: parsed.allow_http_gets }), |
| 59 | ...(Array.isArray(parsed.allowed_methods) && { codeAllowedMethods: parsed.allowed_methods }), |
| 60 | ...(Array.isArray(parsed.blocked_methods) && { codeBlockedMethods: parsed.blocked_methods }), |
| 61 | }; |
| 62 | getLogger().info( |
| 63 | { clientPermissions: parsed }, |
| 64 | 'Overriding code execution permissions from x-stainless-mcp-client-permissions header', |
| 65 | ); |
| 66 | } |
| 67 | } catch (error) { |
| 68 | getLogger().warn({ error }, 'Failed to parse x-stainless-mcp-client-permissions header'); |
| 69 | } |
| 70 | } |
| 71 | |
| 72 | const mcpClientInfo = |
| 73 | typeof req.body?.params?.clientInfo?.name === 'string' ? |
| 74 | { name: req.body.params.clientInfo.name, version: String(req.body.params.clientInfo.version ?? '') } |
| 75 | : undefined; |
| 76 | |
| 77 | await initMcpServer({ |
| 78 | server: server, |
| 79 | mcpOptions: effectiveMcpOptions, |
| 80 | clientOptions: { |
| 81 | ...clientOptions, |
| 82 | ...authOptions, |
| 83 | }, |
| 84 | stainlessApiKey: stainlessApiKey, |
| 85 | upstreamClientEnvs, |
| 86 | mcpSessionId: (req as any).mcpSessionId, |
| 87 | mcpClientInfo, |
| 88 | }); |
| 89 | |
| 90 | if (mcpClientInfo) { |
| 91 | getLogger().info({ mcpSessionId: (req as any).mcpSessionId, mcpClientInfo }, 'MCP client connected'); |
| 92 | } |
| 93 | |
| 94 | return server; |
| 95 | }; |
| 96 | |
| 97 | const post = |
| 98 | (options: { clientOptions: ClientOptions; mcpOptions: McpOptions }) => |
| 99 | async (req: express.Request, res: express.Response) => { |
| 100 | const server = await newServer({ ...options, req, res }); |
| 101 | // If we return null, we already set the authorization error. |
| 102 | if (server === null) return; |
| 103 | const transport = new StreamableHTTPServerTransport(); |
| 104 | await server.connect(transport as any); |
| 105 | await transport.handleRequest(req, res, req.body); |
| 106 | }; |
| 107 | |
| 108 | const get = async (req: express.Request, res: express.Response) => { |
| 109 | res.status(405).json({ |
| 110 | jsonrpc: '2.0', |
| 111 | error: { |
| 112 | code: -32000, |
| 113 | message: 'Method not supported', |
| 114 | }, |
| 115 | }); |
| 116 | }; |
| 117 | |
| 118 | const del = async (req: express.Request, res: express.Response) => { |
| 119 | res.status(405).json({ |
| 120 | jsonrpc: '2.0', |
| 121 | error: { |
| 122 | code: -32000, |
| 123 | message: 'Method not supported', |
| 124 | }, |
| 125 | }); |
| 126 | }; |
| 127 | |
| 128 | const redactHeaders = (headers: Record<string, any>) => { |
| 129 | const hiddenHeaders = /auth|cookie|key|token|x-stainless-mcp-client-envs/i; |
| 130 | const filtered = { ...headers }; |
| 131 | Object.keys(filtered).forEach((key) => { |
| 132 | if (hiddenHeaders.test(key)) { |
| 133 | filtered[key] = '[REDACTED]'; |
| 134 | } |
| 135 | }); |
| 136 | return filtered; |
| 137 | }; |
| 138 | |
| 139 | export const streamableHTTPApp = ({ |
| 140 | clientOptions = {}, |
| 141 | mcpOptions, |
| 142 | }: { |
| 143 | clientOptions?: ClientOptions; |
| 144 | mcpOptions: McpOptions; |
| 145 | }): express.Express => { |
| 146 | const app = express(); |
| 147 | app.set('query parser', 'extended'); |
| 148 | app.use(express.json()); |
| 149 | app.use((req: express.Request, res: express.Response, next: express.NextFunction) => { |
| 150 | const existing = req.headers['mcp-session-id']; |
| 151 | const sessionId = (Array.isArray(existing) ? existing[0] : existing) || crypto.randomUUID(); |
| 152 | (req as any).mcpSessionId = sessionId; |
| 153 | const origWriteHead = res.writeHead.bind(res); |
| 154 | res.writeHead = function (statusCode: number, ...rest: any[]) { |
| 155 | res.setHeader('mcp-session-id', sessionId); |
| 156 | return origWriteHead(statusCode, ...rest); |
| 157 | } as typeof res.writeHead; |
| 158 | next(); |
| 159 | }); |
| 160 | app.use( |
| 161 | pinoHttp({ |
| 162 | logger: getLogger(), |
| 163 | customProps: (req) => ({ |
| 164 | mcpSessionId: (req as any).mcpSessionId, |
| 165 | }), |
| 166 | customLogLevel: (req, res) => { |
| 167 | if (res.statusCode >= 500) { |
| 168 | return 'error'; |
| 169 | } else if (res.statusCode >= 400) { |
| 170 | return 'warn'; |
| 171 | } |
| 172 | return 'info'; |
| 173 | }, |
| 174 | customSuccessMessage: function (req, res) { |
| 175 | return `Request ${req.method} to ${req.url} completed with status ${res.statusCode}`; |
| 176 | }, |
| 177 | customErrorMessage: function (req, res, err) { |
| 178 | return `Request ${req.method} to ${req.url} errored with status ${res.statusCode}`; |
| 179 | }, |
| 180 | serializers: { |
| 181 | req: pino.stdSerializers.wrapRequestSerializer((req) => { |
| 182 | return { |
| 183 | ...req, |
| 184 | headers: redactHeaders(req.raw.headers), |
| 185 | }; |
| 186 | }), |
| 187 | res: pino.stdSerializers.wrapResponseSerializer((res) => { |
| 188 | return { |
| 189 | ...res, |
| 190 | headers: redactHeaders(res.headers), |
| 191 | }; |
| 192 | }), |
| 193 | }, |
| 194 | }), |
| 195 | ); |
| 196 | |
| 197 | app.get('/health', async (req: express.Request, res: express.Response) => { |
| 198 | res.status(200).send('OK'); |
| 199 | }); |
| 200 | app.get('/', get); |
| 201 | app.post('/', post({ clientOptions, mcpOptions })); |
| 202 | app.delete('/', del); |
| 203 | |
| 204 | return app; |
| 205 | }; |
| 206 | |
| 207 | export const launchStreamableHTTPServer = async ({ |
| 208 | mcpOptions, |
| 209 | port, |
| 210 | }: { |
| 211 | mcpOptions: McpOptions; |
| 212 | port: number | string | undefined; |
| 213 | }) => { |
| 214 | const app = streamableHTTPApp({ mcpOptions }); |
| 215 | const server = app.listen(port); |
| 216 | const address = server.address(); |
| 217 | |
| 218 | const logger = getLogger(); |
| 219 | |
| 220 | if (typeof address === 'string') { |
| 221 | logger.info(`MCP Server running on streamable HTTP at ${address}`); |
| 222 | } else if (address !== null) { |
| 223 | logger.info(`MCP Server running on streamable HTTP on port ${address.port}`); |
| 224 | } else { |
| 225 | logger.info(`MCP Server running on streamable HTTP on port ${port}`); |
| 226 | } |
| 227 | }; |
| 228 | |