Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 13 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <base64(apiKeyId:apiKeySecret)>",
"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.
23 changes: 15 additions & 8 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,25 @@ const port = process.env.PORT || 3000;
export async function startServer(): Promise<void> {
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);
}
}

Expand Down
12 changes: 8 additions & 4 deletions src/common/config.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,23 @@
import type { Env0Config } from '../env0-service/env0-client';

export function getAndValidateConfig(overrides?: Partial<Env0Config>): Env0Config {
export function getAndValidateConfig(
overrides?: Partial<Env0Config>,
// 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 || '',
apiKeyId: overrides?.apiKeyId || process.env.ENV0_API_KEY || '',
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)');
}

Expand Down
6 changes: 5 additions & 1 deletion src/env0-service/env0-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
41 changes: 38 additions & 3 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,34 @@ const transports = {
streamable: {} as Record<string, StreamableHTTPServerTransport>,
sse: {} as Record<string, SSEServerTransport>
};
// Per-session MCP servers, each bound to that client's forwarded credentials
const servers = {
streamable: {} as Record<string, McpServer>,
sse: {} as Record<string, McpServer>
};

// 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<void> {
export async function startHttpServer(port: number, createServer: McpServerFactory): Promise<void> {
const app = express();

// Parse JSON requests for the Streamable HTTP endpoint only, will break SSE endpoint
Expand All @@ -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);
Expand Down Expand Up @@ -113,14 +146,16 @@ export async function startHttpServer(port: number, mcpServer: McpServer): Promi

app.get('/sse', async (req, res): Promise<void> => {
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);
Expand Down
Loading