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
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:
parent
2ffe2bb6d8
commit
8b354bb861
@ -562,6 +562,7 @@ export interface CreateMcpServerRequest {
|
|||||||
auth_type: "none" | "api_key" | "bearer" | "oauth2";
|
auth_type: "none" | "api_key" | "bearer" | "oauth2";
|
||||||
auth_value?: string;
|
auth_value?: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
env_config?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpdateMcpServerRequest {
|
export interface UpdateMcpServerRequest {
|
||||||
@ -572,6 +573,7 @@ export interface UpdateMcpServerRequest {
|
|||||||
auth_type?: "none" | "api_key" | "bearer" | "oauth2";
|
auth_type?: "none" | "api_key" | "bearer" | "oauth2";
|
||||||
auth_value?: string;
|
auth_value?: string;
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
env_config?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── MCP Commands ─────────────────────────────────────────────────────────────
|
// ─── MCP Commands ─────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -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"];
|
type StatusKey = McpServerStatus["status"];
|
||||||
|
|
||||||
const statusColors: Record<StatusKey, string> = {
|
const statusColors: Record<StatusKey, string> = {
|
||||||
@ -72,6 +108,9 @@ interface ServerForm {
|
|||||||
auth_type: "none" | "api_key" | "bearer" | "oauth2";
|
auth_type: "none" | "api_key" | "bearer" | "oauth2";
|
||||||
auth_value: string;
|
auth_value: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
plaintext_env: string;
|
||||||
|
encrypted_env: string;
|
||||||
|
http_headers: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const emptyForm: ServerForm = {
|
const emptyForm: ServerForm = {
|
||||||
@ -83,6 +122,9 @@ const emptyForm: ServerForm = {
|
|||||||
auth_type: "none",
|
auth_type: "none",
|
||||||
auth_value: "",
|
auth_value: "",
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
plaintext_env: "",
|
||||||
|
encrypted_env: "",
|
||||||
|
http_headers: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function MCPServers() {
|
export default function MCPServers() {
|
||||||
@ -155,6 +197,21 @@ export default function MCPServers() {
|
|||||||
|
|
||||||
const startEdit = (server: McpServer) => {
|
const startEdit = (server: McpServer) => {
|
||||||
const parsed = parseTransportConfig(server.transport_config);
|
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({
|
setForm({
|
||||||
name: server.name,
|
name: server.name,
|
||||||
url: server.url,
|
url: server.url,
|
||||||
@ -164,6 +221,9 @@ export default function MCPServers() {
|
|||||||
auth_type: server.auth_type,
|
auth_type: server.auth_type,
|
||||||
auth_value: "",
|
auth_value: "",
|
||||||
enabled: server.enabled,
|
enabled: server.enabled,
|
||||||
|
plaintext_env: plaintextEnv,
|
||||||
|
encrypted_env: "", // Never populate (security: don't show encrypted values)
|
||||||
|
http_headers: httpHeaders,
|
||||||
});
|
});
|
||||||
setEditServer(server);
|
setEditServer(server);
|
||||||
setIsAdding(true);
|
setIsAdding(true);
|
||||||
@ -180,10 +240,25 @@ export default function MCPServers() {
|
|||||||
if (form.transport_type === "http" && !form.url) return;
|
if (form.transport_type === "http" && !form.url) return;
|
||||||
if (form.transport_type === "stdio" && !form.command) 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 =
|
const transportConfig =
|
||||||
form.transport_type === "stdio"
|
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 : "";
|
const url = form.transport_type === "http" ? form.url : "";
|
||||||
|
|
||||||
@ -196,6 +271,7 @@ export default function MCPServers() {
|
|||||||
transport_config: transportConfig,
|
transport_config: transportConfig,
|
||||||
auth_type: form.auth_type,
|
auth_type: form.auth_type,
|
||||||
enabled: form.enabled,
|
enabled: form.enabled,
|
||||||
|
env_config: envConfig,
|
||||||
};
|
};
|
||||||
if (form.auth_value) {
|
if (form.auth_value) {
|
||||||
request.auth_value = form.auth_value;
|
request.auth_value = form.auth_value;
|
||||||
@ -210,6 +286,7 @@ export default function MCPServers() {
|
|||||||
auth_type: form.auth_type,
|
auth_type: form.auth_type,
|
||||||
auth_value: form.auth_value || undefined,
|
auth_value: form.auth_value || undefined,
|
||||||
enabled: form.enabled,
|
enabled: form.enabled,
|
||||||
|
env_config: envConfig,
|
||||||
};
|
};
|
||||||
await createMcpServerCmd(request);
|
await createMcpServerCmd(request);
|
||||||
}
|
}
|
||||||
@ -475,6 +552,62 @@ export default function MCPServers() {
|
|||||||
</div>
|
</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 />
|
<Separator />
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user