diff --git a/README.md b/README.md index 9b1d8aa..a45256d 100644 --- a/README.md +++ b/README.md @@ -977,21 +977,31 @@ The container supports both MCP transport modes: #### 2. HTTP Transport -- For remote MCP server access +- For remote MCP server access (self-host once, serve many users) - Set `MCP_TRANSPORT` environment variable to `http` to enable +- **Credentials are provided per-client via request headers, not server env vars.** Each client acts under its own env0 API key (and therefore its own RBAC role), so the server holds no shared key: ```bash +# No ENV0_API_KEY / ENV0_API_SECRET needed on the server in HTTP mode docker run -d -p 3000:3000 -e PORT=3000 -e MCP_TRANSPORT=http env0/mcp-server ``` -Then, configure your MCP client to use the HTTP transport mode: +Then, configure your MCP client to send its credentials as headers: ```json { "mcpServers": { "env0": { - "serverUrl": "http://localhost:3000/mcp" + "url": "http://localhost:3000/mcp", + "headers": { + "Authorization": "Basic ", + "x-env0-organization-id": "your-org-id-here" + } } } } ``` + +The `Authorization` header is forwarded verbatim to the env0 API. Requests without it are rejected with `401 Unauthorized`. (`ENV0_API_URL` may still be set on the server to target a non-default env0 API.) + +> ⚠️ **Run behind TLS.** Remote HTTP mode sends env0 API keys per request. You should run behind TLS (reverse proxy / LB). Do not expose the plain HTTP port to a network. diff --git a/src/cli.ts b/src/cli.ts index e3aa813..d789cb2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -14,18 +14,25 @@ const port = process.env.PORT || 3000; export async function startServer(): Promise { const isHttpMode = process.argv.includes('--http') || process.env.MCP_TRANSPORT === 'http'; - const config = getAndValidateConfig(); - const env0Client = new Env0Client(config); - const env0Service = new Env0Service(config, env0Client); - - const server = createMcpServer(env0Service); - if (isHttpMode) { + // HTTP mode: credentials come per-request from each client's headers, not env vars + const baseConfig = getAndValidateConfig(undefined, false); console.log(`Initializing Env0 MCP Server in HTTP mode on port ${port}...`); - await startHttpServer(+port, server); + await startHttpServer(+port, headers => { + const orgId = headers['x-env0-organization-id']; + const config = { + ...baseConfig, + organizationId: (Array.isArray(orgId) ? orgId[0] : orgId) || baseConfig.organizationId, + // authorization is guaranteed present (buildSessionServer 401s otherwise) + ...(headers.authorization ? { authHeader: headers.authorization } : {}) + }; + return createMcpServer(new Env0Service(config, new Env0Client(config))); + }); } else { + const config = getAndValidateConfig(); + const env0Service = new Env0Service(config, new Env0Client(config)); const transport = new StdioServerTransport(); - await server.connect(transport); + await createMcpServer(env0Service).connect(transport); } } diff --git a/src/common/config.ts b/src/common/config.ts index 39e670b..1d33cb2 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -1,6 +1,10 @@ import type { Env0Config } from '../env0-service/env0-client'; -export function getAndValidateConfig(overrides?: Partial): Env0Config { +export function getAndValidateConfig( + overrides?: Partial, + // HTTP mode receives credentials per-request, so the startup env-var key is optional there + requireCredentials = true +): Env0Config { const config: Env0Config = { apiUrl: overrides?.apiUrl || process.env.ENV0_API_URL || 'https://api.env0.com', organizationId: overrides?.organizationId || process.env.ENV0_ORGANIZATION_ID || '', @@ -8,12 +12,12 @@ export function getAndValidateConfig(overrides?: Partial): Env0Confi apiKeySecret: overrides?.apiKeySecret || process.env.ENV0_API_SECRET || '' }; - validateConfig(config); + validateConfig(config, requireCredentials); return config; } -function validateConfig(config: Env0Config): void { - if (!config.apiKeyId || !config.apiKeySecret) { +function validateConfig(config: Env0Config, requireCredentials: boolean): void { + if (requireCredentials && (!config.apiKeyId || !config.apiKeySecret)) { throw new Error('Missing required env0 API credentials (ENV0_API_KEY and ENV0_API_SECRET)'); } diff --git a/src/env0-service/env0-client.ts b/src/env0-service/env0-client.ts index 0867920..d6d1491 100644 --- a/src/env0-service/env0-client.ts +++ b/src/env0-service/env0-client.ts @@ -5,13 +5,17 @@ export interface Env0Config { apiUrl: string; apiKeyId: string; apiKeySecret: string; + // ponytail: forwarded verbatim (HTTP per-client mode); falls back to id:secret Basic + authHeader?: string; } export class Env0Client { private readonly client: AxiosInstance; constructor(config: Env0Config) { - const authHeader = `Basic ${Buffer.from(`${config.apiKeyId}:${config.apiKeySecret}`).toString('base64')}`; + const authHeader = + config.authHeader ?? + `Basic ${Buffer.from(`${config.apiKeyId}:${config.apiKeySecret}`).toString('base64')}`; this.client = axios.create({ baseURL: config.apiUrl, diff --git a/src/server.ts b/src/server.ts index cde65e3..037822d 100644 --- a/src/server.ts +++ b/src/server.ts @@ -11,8 +11,34 @@ const transports = { streamable: {} as Record, sse: {} as Record }; +// Per-session MCP servers, each bound to that client's forwarded credentials +const servers = { + streamable: {} as Record, + sse: {} as Record +}; + +// Builds an MCP server bound to the credentials in the request headers. +// Returns null (and writes a 401) when the Authorization header is missing. +// eslint-disable-next-line no-unused-vars +export type McpServerFactory = (requestHeaders: Request['headers']) => McpServer; + +function buildSessionServer( + req: Request, + res: Response, + createServer: McpServerFactory +): McpServer | null { + if (!req.headers.authorization) { + res.status(401).json({ + jsonrpc: '2.0', + error: { code: -32001, message: 'Unauthorized: missing Authorization header' }, + id: null + }); + return null; + } + return createServer(req.headers); +} -export async function startHttpServer(port: number, mcpServer: McpServer): Promise { +export async function startHttpServer(port: number, createServer: McpServerFactory): Promise { const app = express(); // Parse JSON requests for the Streamable HTTP endpoint only, will break SSE endpoint @@ -23,21 +49,28 @@ export async function startHttpServer(port: number, mcpServer: McpServer): Promi const sessionId = req.headers['mcp-session-id'] as string | undefined; let transport: StreamableHTTPServerTransport; + let mcpServer: McpServer; if (sessionId && transports.streamable[sessionId]) { console.log('Reusing existing StreamableHTTP transport for sessionId', sessionId); transport = transports.streamable[sessionId]; + mcpServer = servers.streamable[sessionId]!; } else if (!sessionId && isInitializeRequest(req.body)) { console.log('New initialization request for StreamableHTTP sessionId', sessionId); + const sessionServer = buildSessionServer(req, res, createServer); + if (!sessionServer) return; + mcpServer = sessionServer; transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), onsessioninitialized: sessionId => { transports.streamable[sessionId] = transport; + servers.streamable[sessionId] = mcpServer; } }); transport.onclose = () => { if (transport.sessionId) { delete transports.streamable[transport.sessionId]; + delete servers.streamable[transport.sessionId]; } }; await mcpServer.connect(transport); @@ -113,14 +146,16 @@ export async function startHttpServer(port: number, mcpServer: McpServer): Promi app.get('/sse', async (req, res): Promise => { console.log('Establishing new SSE connection'); + const mcpServer = buildSessionServer(req, res, createServer); + if (!mcpServer) return; const transport = new SSEServerTransport('/messages', res); console.log(`New SSE connection established for sessionId ${transport.sessionId}`); - console.log('/sse request headers:', req.headers); - console.log('/sse request body:', req.body); transports.sse[transport.sessionId] = transport; + servers.sse[transport.sessionId] = mcpServer; res.on('close', () => { delete transports.sse[transport.sessionId]; + delete servers.sse[transport.sessionId]; }); await mcpServer.connect(transport);