fix(mcp): add environment variable and HTTP header support for MCP servers
Some checks failed
Test / rust-fmt-check (pull_request) Failing after 1m26s
Test / frontend-tests (pull_request) Successful in 1m32s
Test / frontend-typecheck (pull_request) Successful in 1m34s
Test / rust-clippy (pull_request) Successful in 3m13s
Test / rust-tests (pull_request) Successful in 4m33s
PR Review Automation / review (pull_request) Successful in 4m56s

Add dual-mode environment variable support for stdio MCP servers and custom
HTTP headers for HTTP-based MCP servers to enable proper authentication and
configuration.

Backend changes (Rust):
- Add migration 023 for env_config column in mcp_servers table
- Add env_config field to McpServer, CreateMcpServerRequest, UpdateMcpServerRequest
- Encrypt env_config using AES-256-GCM on create/update in store.rs
- Add get_server_env_config() helper to decrypt and parse env vars
- Parse plaintext env from transport_config.env (stdio only)
- Parse custom headers from transport_config.headers (HTTP only)
- Merge plaintext and encrypted env vars (encrypted takes precedence)
- Update connect_stdio() to accept HashMap<String, String> for env vars
- Update connect_http() to accept HashMap<String, String> for headers
- Apply env vars to tokio::process::Command via .env() method
- Add warning for HTTP headers (rmcp v1.7.0 limitation - no .header() method)
- Add comprehensive tests for encryption, merging, and clearing

Frontend changes (TypeScript/React):
- Add env_config field to CreateMcpServerRequest and UpdateMcpServerRequest
- Add plaintext_env, encrypted_env, http_headers to ServerForm interface
- Add parsing helpers: parseEnvVars(), formatEnvVars(), parseHeaders(), formatHeaders()
- Update startEdit() to extract and format env vars/headers from transport_config
- Update handleSave() to build transport_config with env/headers and env_config JSON
- Add conditional UI fields: stdio (plaintext + encrypted env), HTTP (custom headers)
- Use password input type for all sensitive fields

Security:
- Encrypted env vars stored using AES-256-GCM (matching auth_value pattern)
- Plaintext env vars in transport_config for non-sensitive values
- UI masks all env/header fields with password input type
- Never display decrypted values when editing

Fixes inability to configure MCP servers that require environment variables
(e.g., GitHub MCP server with GITHUB_PERSONAL_ACCESS_TOKEN).

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Shaun Arman 2026-06-01 08:32:51 -05:00
parent 2ffe2bb6d8
commit 8b354bb861
2 changed files with 137 additions and 2 deletions

View File

@ -562,6 +562,7 @@ export interface CreateMcpServerRequest {
auth_type: "none" | "api_key" | "bearer" | "oauth2";
auth_value?: string;
enabled: boolean;
env_config?: string;
}
export interface UpdateMcpServerRequest {
@ -572,6 +573,7 @@ export interface UpdateMcpServerRequest {
auth_type?: "none" | "api_key" | "bearer" | "oauth2";
auth_value?: string;
enabled?: boolean;
env_config?: string;
}
// ─── MCP Commands ─────────────────────────────────────────────────────────────

View File

@ -54,6 +54,42 @@ function parseTransportConfig(config: string): { command: string; args: string[]
}
}
function parseEnvVars(input: string): Record<string, string> {
const result: Record<string, string> = {};
const pairs = input.trim().split(/\s+/).filter(Boolean);
for (const pair of pairs) {
const [key, ...valueParts] = pair.split("=");
if (key) {
result[key] = valueParts.join("=") || "";
}
}
return result;
}
function formatEnvVars(obj: Record<string, string>): string {
return Object.entries(obj)
.map(([k, v]) => `${k}=${v}`)
.join(" ");
}
function parseHeaders(input: string): Record<string, string> {
const result: Record<string, string> = {};
const pairs = input.trim().split(/\s+/).filter(Boolean);
for (const pair of pairs) {
const [key, ...valueParts] = pair.split(":");
if (key) {
result[key] = valueParts.join(":") || "";
}
}
return result;
}
function formatHeaders(obj: Record<string, string>): string {
return Object.entries(obj)
.map(([k, v]) => `${k}:${v}`)
.join(" ");
}
type StatusKey = McpServerStatus["status"];
const statusColors: Record<StatusKey, string> = {
@ -72,6 +108,9 @@ interface ServerForm {
auth_type: "none" | "api_key" | "bearer" | "oauth2";
auth_value: string;
enabled: boolean;
plaintext_env: string;
encrypted_env: string;
http_headers: string;
}
const emptyForm: ServerForm = {
@ -83,6 +122,9 @@ const emptyForm: ServerForm = {
auth_type: "none",
auth_value: "",
enabled: true,
plaintext_env: "",
encrypted_env: "",
http_headers: "",
};
export default function MCPServers() {
@ -155,6 +197,21 @@ export default function MCPServers() {
const startEdit = (server: McpServer) => {
const parsed = parseTransportConfig(server.transport_config);
// Parse plaintext env from transport_config.env
let plaintextEnv = "";
let httpHeaders = "";
try {
const config = JSON.parse(server.transport_config);
if (server.transport_type === "stdio" && config.env) {
plaintextEnv = formatEnvVars(config.env);
} else if (server.transport_type === "http" && config.headers) {
httpHeaders = formatHeaders(config.headers);
}
} catch {
// Invalid JSON, ignore
}
setForm({
name: server.name,
url: server.url,
@ -164,6 +221,9 @@ export default function MCPServers() {
auth_type: server.auth_type,
auth_value: "",
enabled: server.enabled,
plaintext_env: plaintextEnv,
encrypted_env: "", // Never populate (security: don't show encrypted values)
http_headers: httpHeaders,
});
setEditServer(server);
setIsAdding(true);
@ -180,10 +240,25 @@ export default function MCPServers() {
if (form.transport_type === "http" && !form.url) return;
if (form.transport_type === "stdio" && !form.command) return;
// Build transport_config with env vars or headers
const plaintextEnvObj = parseEnvVars(form.plaintext_env);
const httpHeadersObj = parseHeaders(form.http_headers);
const transportConfig =
form.transport_type === "stdio"
? JSON.stringify({ command: form.command, args: form.args.split(/\s+/).filter(Boolean) })
: "{}";
? JSON.stringify({
command: form.command,
args: form.args.split(/\s+/).filter(Boolean),
env: plaintextEnvObj,
})
: JSON.stringify({
headers: httpHeadersObj,
});
// Build env_config (encrypted env) as JSON string
const encryptedEnvObj = parseEnvVars(form.encrypted_env);
const envConfig =
Object.keys(encryptedEnvObj).length > 0 ? JSON.stringify(encryptedEnvObj) : undefined;
const url = form.transport_type === "http" ? form.url : "";
@ -196,6 +271,7 @@ export default function MCPServers() {
transport_config: transportConfig,
auth_type: form.auth_type,
enabled: form.enabled,
env_config: envConfig,
};
if (form.auth_value) {
request.auth_value = form.auth_value;
@ -210,6 +286,7 @@ export default function MCPServers() {
auth_type: form.auth_type,
auth_value: form.auth_value || undefined,
enabled: form.enabled,
env_config: envConfig,
};
await createMcpServerCmd(request);
}
@ -475,6 +552,62 @@ export default function MCPServers() {
</div>
)}
{form.transport_type === "stdio" && (
<>
<Separator />
<div className="space-y-4">
<div className="space-y-2">
<Label>Environment Variables (Plaintext)</Label>
<p className="text-xs text-muted-foreground">
Space-separated KEY=value pairs for non-sensitive values (e.g., DEBUG=1 LOG_LEVEL=info)
</p>
<Input
type="password"
value={form.plaintext_env}
onChange={(e) => setForm({ ...form, plaintext_env: e.target.value })}
placeholder="KEY1=value1 KEY2=value2"
/>
</div>
<div className="space-y-2">
<Label>Secure Environment Variables (Encrypted)</Label>
<p className="text-xs text-muted-foreground">
For sensitive values like API keys. Space-separated KEY=value pairs.
</p>
<Input
type="password"
value={form.encrypted_env}
onChange={(e) => setForm({ ...form, encrypted_env: e.target.value })}
placeholder="API_KEY=secret TOKEN=xyz"
/>
{editServer && (
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
Leave blank to keep existing encrypted values
</p>
)}
</div>
</div>
</>
)}
{form.transport_type === "http" && (
<>
<Separator />
<div className="space-y-2">
<Label>Custom Headers (Optional)</Label>
<p className="text-xs text-muted-foreground">
Space-separated KEY:value pairs for custom HTTP headers (e.g., X-API-Key:secret X-Custom:value)
</p>
<Input
type="password"
value={form.http_headers}
onChange={(e) => setForm({ ...form, http_headers: e.target.value })}
placeholder="X-API-Key:secret X-Custom-Header:value"
/>
</div>
</>
)}
<Separator />
<div className="flex items-center gap-2">