Compare commits

..

No commits in common. "8b354bb8615cfff5a67696986afb1607c7fe2570" and "95a63e18bf2cff175150fe2b59c54a15f04f2739" have entirely different histories.

9 changed files with 20 additions and 442 deletions

View File

@ -283,10 +283,6 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
FROM image_attachments ia
JOIN issues i ON i.id = ia.issue_id;",
),
(
"023_add_mcp_env_config",
"ALTER TABLE mcp_servers ADD COLUMN env_config TEXT",
),
];
for (name, sql) in migrations {
@ -1237,39 +1233,4 @@ mod tests {
assert_eq!(count, 1, "{migration} should be recorded exactly once");
}
}
// ─── Migration 023: MCP env_config ──────────────────────────────────────────
#[test]
fn test_023_mcp_env_config_column() {
let conn = setup_test_db();
let mut stmt = conn.prepare("PRAGMA table_info(mcp_servers)").unwrap();
let columns: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(
columns.contains(&"env_config".to_string()),
"mcp_servers table should have env_config column after migration 023"
);
}
#[test]
fn test_023_idempotent() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
run_migrations(&conn).unwrap();
let applied: i64 = conn
.query_row(
"SELECT COUNT(*) FROM _migrations WHERE name = '023_add_mcp_env_config'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(applied, 1, "023 should only be recorded once");
}
}

View File

@ -7,26 +7,17 @@ use crate::mcp::models::{McpResource, McpTool};
/// Live connection to an MCP server.
pub type McpConnection = RunningService<RoleClient, ()>;
/// Connect to a stdio MCP server with optional environment variables.
pub async fn connect_stdio(
command: &str,
args: &[String],
env: std::collections::HashMap<String, String>,
) -> Result<McpConnection, String> {
let transport = crate::mcp::transport::stdio::build_stdio_transport(command, args, env)?;
/// Connect to a stdio MCP server.
pub async fn connect_stdio(command: &str, args: &[String]) -> Result<McpConnection, String> {
let transport = crate::mcp::transport::stdio::build_stdio_transport(command, args)?;
().serve(transport)
.await
.map_err(|e| format!("MCP stdio connection failed: {e}"))
}
/// Connect to an HTTP MCP server with optional custom headers.
pub async fn connect_http(
url: &str,
auth_header: Option<&str>,
custom_headers: std::collections::HashMap<String, String>,
) -> Result<McpConnection, String> {
let transport =
crate::mcp::transport::http::build_http_transport(url, auth_header, custom_headers);
/// Connect to an HTTP MCP server.
pub async fn connect_http(url: &str, auth_header: Option<&str>) -> Result<McpConnection, String> {
let transport = crate::mcp::transport::http::build_http_transport(url, auth_header);
().serve(transport)
.await
.map_err(|e| format!("MCP HTTP connection failed: {e}"))

View File

@ -5,8 +5,7 @@ use tracing::{info, warn};
use crate::mcp::client::{connect_http, connect_stdio, list_resources, list_tools, McpConnection};
use crate::mcp::models::McpServer;
use crate::mcp::store::{
get_server_auth_value, get_server_env_config, list_servers, replace_resources, replace_tools,
update_discovery_status,
get_server_auth_value, list_servers, replace_resources, replace_tools, update_discovery_status,
};
/// Discover a single MCP server: connect, list tools/resources, persist.
@ -56,49 +55,11 @@ async fn discover_server_inner(
.collect()
})
.unwrap_or_default();
// Parse plaintext env vars from transport_config.env
let plaintext_env: std::collections::HashMap<String, String> = config
.get("env")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
// Decrypt and parse encrypted env vars from env_config column
let encrypted_env = {
let db = state.db.lock().map_err(|e| e.to_string())?;
get_server_env_config(&db, &server.id)?
};
// Merge env vars (encrypted takes precedence over plaintext)
let mut merged_env = plaintext_env;
if let Some(enc_env) = encrypted_env {
merged_env.extend(enc_env);
}
connect_stdio(command, &args, merged_env).await?
connect_stdio(command, &args).await?
}
"http" => {
let auth_header = auth_value.as_deref();
// Parse custom headers from transport_config.headers
let config: serde_json::Value =
serde_json::from_str(&server.transport_config).unwrap_or_default();
let custom_headers: std::collections::HashMap<String, String> = config
.get("headers")
.and_then(|v| v.as_object())
.map(|obj| {
obj.iter()
.filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
.collect()
})
.unwrap_or_default();
connect_http(&server.url, auth_header, custom_headers).await?
connect_http(&server.url, auth_header).await?
}
other => return Err(format!("Unknown transport type: {other}")),
};

View File

@ -18,8 +18,6 @@ pub struct McpServer {
pub discovery_error: Option<String>,
pub created_at: String,
pub updated_at: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub env_config: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -66,8 +64,6 @@ pub struct CreateMcpServerRequest {
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_value: Option<String>,
pub enabled: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub env_config: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@ -86,6 +82,4 @@ pub struct UpdateMcpServerRequest {
pub auth_value: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub enabled: Option<bool>,
#[serde(skip_serializing_if = "Option::is_none")]
pub env_config: Option<String>,
}

View File

@ -15,15 +15,10 @@ pub fn create_server(conn: &Connection, req: &CreateMcpServerRequest) -> Result<
_ => None,
};
let encrypted_env = match &req.env_config {
Some(env_json) if !env_json.trim().is_empty() => Some(encrypt_token(env_json)?),
_ => None,
};
conn.execute(
"INSERT INTO mcp_servers
(id, name, url, transport_type, transport_config, auth_type, auth_value, enabled, env_config, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)",
(id, name, url, transport_type, transport_config, auth_type, auth_value, enabled, created_at, updated_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?9)",
rusqlite::params![
id,
req.name,
@ -33,7 +28,6 @@ pub fn create_server(conn: &Connection, req: &CreateMcpServerRequest) -> Result<
req.auth_type,
encrypted_auth,
req.enabled as i32,
encrypted_env,
now,
],
)
@ -47,7 +41,7 @@ pub fn get_server(conn: &Connection, id: &str) -> Result<Option<McpServer>, Stri
.query_row(
"SELECT id, name, url, transport_type, transport_config, auth_type, auth_value,
enabled, last_discovered_at, discovery_status, discovery_error,
created_at, updated_at, env_config
created_at, updated_at
FROM mcp_servers WHERE id = ?1",
[id],
|row| {
@ -65,7 +59,6 @@ pub fn get_server(conn: &Connection, id: &str) -> Result<Option<McpServer>, Stri
discovery_error: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
env_config: row.get(13)?,
})
},
)
@ -79,7 +72,7 @@ pub fn list_servers(conn: &Connection) -> Result<Vec<McpServer>, String> {
.prepare(
"SELECT id, name, url, transport_type, transport_config, auth_type, auth_value,
enabled, last_discovered_at, discovery_status, discovery_error,
created_at, updated_at, env_config
created_at, updated_at
FROM mcp_servers ORDER BY created_at ASC",
)
.map_err(|e| e.to_string())?;
@ -100,7 +93,6 @@ pub fn list_servers(conn: &Connection) -> Result<Vec<McpServer>, String> {
discovery_error: row.get(10)?,
created_at: row.get(11)?,
updated_at: row.get(12)?,
env_config: row.get(13)?,
})
})
.map_err(|e| e.to_string())?
@ -124,17 +116,11 @@ pub fn update_server(
None => existing.auth_value.clone(),
};
let new_encrypted_env = match &req.env_config {
Some(env_json) if !env_json.trim().is_empty() => Some(encrypt_token(env_json)?),
Some(_) => None, // Empty string = clear env_config
None => existing.env_config.clone(), // No update requested
};
conn.execute(
"UPDATE mcp_servers SET
name = ?1, url = ?2, transport_type = ?3, transport_config = ?4,
auth_type = ?5, auth_value = ?6, enabled = ?7, env_config = ?8, updated_at = ?9
WHERE id = ?10",
auth_type = ?5, auth_value = ?6, enabled = ?7, updated_at = ?8
WHERE id = ?9",
rusqlite::params![
req.name.as_deref().unwrap_or(&existing.name),
req.url.as_deref().unwrap_or(&existing.url),
@ -149,7 +135,6 @@ pub fn update_server(
req.enabled
.map(|b| b as i32)
.unwrap_or(existing.enabled as i32),
new_encrypted_env,
now,
id,
],
@ -323,33 +308,6 @@ pub fn get_resource_count(conn: &Connection, server_id: &str) -> Result<usize, S
.map_err(|e| e.to_string())
}
/// Decrypt and parse env_config from database, returning a HashMap.
/// Returns None if env_config is NULL, or an error if decryption/parsing fails.
pub fn get_server_env_config(
conn: &Connection,
server_id: &str,
) -> Result<Option<std::collections::HashMap<String, String>>, String> {
let encrypted: Option<String> = conn
.query_row(
"SELECT env_config FROM mcp_servers WHERE id = ?1",
[server_id],
|row| row.get(0),
)
.optional()
.map_err(|e| e.to_string())?
.flatten();
match encrypted {
Some(enc) => {
let decrypted = decrypt_token(&enc)?;
let parsed: std::collections::HashMap<String, String> = serde_json::from_str(&decrypted)
.map_err(|e| format!("Failed to parse env_config JSON: {e}"))?;
Ok(Some(parsed))
}
None => Ok(None),
}
}
#[cfg(test)]
mod tests {
use super::*;
@ -370,7 +328,6 @@ mod tests {
auth_type: "none".to_string(),
auth_value: None,
enabled: true,
env_config: None,
}
}
@ -405,7 +362,6 @@ mod tests {
auth_type: None,
auth_value: None,
enabled: None,
env_config: None,
},
)
.unwrap();
@ -429,7 +385,6 @@ mod tests {
auth_type: "bearer".to_string(),
auth_value: Some("super-secret-token".to_string()),
enabled: true,
env_config: None,
};
let server = create_server(&conn, &req).unwrap();
@ -524,128 +479,4 @@ mod tests {
.unwrap();
assert_eq!(count, 0, "cascade delete should clear mcp_tools");
}
#[test]
fn test_env_config_encrypted_at_rest() {
let conn = setup();
let req = CreateMcpServerRequest {
name: "Env Test".to_string(),
url: "".to_string(),
transport_type: "stdio".to_string(),
transport_config: r#"{"command":"/usr/bin/test","args":[]}"#.to_string(),
auth_type: "none".to_string(),
auth_value: None,
enabled: true,
env_config: Some(r#"{"API_KEY":"secret123","DEBUG":"1"}"#.to_string()),
};
let server = create_server(&conn, &req).unwrap();
// Raw DB value must be encrypted (not equal to plaintext)
let raw: Option<String> = conn
.query_row(
"SELECT env_config FROM mcp_servers WHERE id = ?1",
[&server.id],
|r| r.get(0),
)
.unwrap();
let raw = raw.unwrap();
assert_ne!(
raw,
r#"{"API_KEY":"secret123","DEBUG":"1"}"#,
"env_config should be encrypted at rest"
);
// Decrypted value must match original
let env_map = get_server_env_config(&conn, &server.id).unwrap().unwrap();
assert_eq!(env_map.get("API_KEY").unwrap(), "secret123");
assert_eq!(env_map.get("DEBUG").unwrap(), "1");
}
#[test]
fn test_update_env_config() {
let conn = setup();
let server = create_server(&conn, &make_req("Env Update")).unwrap();
assert!(server.env_config.is_none());
let updated = update_server(
&conn,
&server.id,
&UpdateMcpServerRequest {
name: None,
url: None,
transport_type: None,
transport_config: None,
auth_type: None,
auth_value: None,
enabled: None,
env_config: Some(r#"{"NEW_VAR":"value"}"#.to_string()),
},
)
.unwrap();
assert!(updated.env_config.is_some());
let env_map = get_server_env_config(&conn, &server.id).unwrap().unwrap();
assert_eq!(env_map.get("NEW_VAR").unwrap(), "value");
}
#[test]
fn test_clear_env_config_with_empty_string() {
let conn = setup();
let mut req = make_req("Clear Env");
req.env_config = Some(r#"{"KEY":"val"}"#.to_string());
let server = create_server(&conn, &req).unwrap();
assert!(server.env_config.is_some());
let updated = update_server(
&conn,
&server.id,
&UpdateMcpServerRequest {
name: None,
url: None,
transport_type: None,
transport_config: None,
auth_type: None,
auth_value: None,
enabled: None,
env_config: Some("".to_string()), // Clear
},
)
.unwrap();
assert!(updated.env_config.is_none());
}
#[test]
fn test_env_config_none_preserves_existing() {
let conn = setup();
let mut req = make_req("Preserve Env");
req.env_config = Some(r#"{"ORIGINAL":"value"}"#.to_string());
let server = create_server(&conn, &req).unwrap();
// Update without touching env_config
let updated = update_server(
&conn,
&server.id,
&UpdateMcpServerRequest {
name: Some("New Name".to_string()),
url: None,
transport_type: None,
transport_config: None,
auth_type: None,
auth_value: None,
enabled: None,
env_config: None, // Don't update
},
)
.unwrap();
// env_config should still be there
assert!(updated.env_config.is_some());
let env_map = get_server_env_config(&conn, &server.id).unwrap().unwrap();
assert_eq!(env_map.get("ORIGINAL").unwrap(), "value");
}
}

View File

@ -1,32 +1,17 @@
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
use rmcp::transport::StreamableHttpClientTransport;
use std::collections::HashMap;
use std::sync::Arc;
/// Build an HTTP (Streamable HTTP) transport from a URL with optional custom headers.
/// Build an HTTP (Streamable HTTP) transport from a URL.
/// Optionally attaches an Authorization bearer token.
///
/// NOTE: Custom headers are parsed but not yet applied due to rmcp v1.7.0 API limitations.
/// The rmcp library's StreamableHttpClientTransportConfig does not expose a .header() method.
/// Custom headers support is deferred until rmcp adds this capability or we find an alternative.
pub fn build_http_transport(
url: &str,
auth_header: Option<&str>,
custom_headers: HashMap<String, String>,
) -> impl rmcp::transport::Transport<rmcp::RoleClient> {
// Log warning if custom headers are provided (not yet supported)
if !custom_headers.is_empty() {
tracing::warn!(
"Custom HTTP headers provided but not supported by rmcp v1.7.0: {:?}",
custom_headers.keys().collect::<Vec<_>>()
);
}
let config = match auth_header {
Some(token) => StreamableHttpClientTransportConfig::with_uri(Arc::from(url))
.auth_header(token.to_string()),
None => StreamableHttpClientTransportConfig::with_uri(Arc::from(url)),
};
StreamableHttpClientTransport::from_config(config)
}

View File

@ -1,15 +1,10 @@
use rmcp::transport::TokioChildProcess;
use std::collections::HashMap;
use std::path::Path;
use tokio::process::Command;
/// Build a stdio transport from a command path, argument list, and environment variables.
/// Build a stdio transport from a command path and argument list.
/// Rejects relative paths to prevent path traversal.
pub fn build_stdio_transport(
command: &str,
args: &[String],
env: HashMap<String, String>,
) -> Result<TokioChildProcess, String> {
pub fn build_stdio_transport(command: &str, args: &[String]) -> Result<TokioChildProcess, String> {
if !Path::new(command).is_absolute() {
return Err(format!(
"stdio command must be an absolute path, got: {command}"
@ -19,10 +14,5 @@ pub fn build_stdio_transport(
let mut cmd = Command::new(command);
cmd.args(args);
// Apply environment variables
for (key, value) in env {
cmd.env(key, value);
}
TokioChildProcess::new(cmd).map_err(|e| format!("Failed to spawn stdio process: {e}"))
}

View File

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

View File

@ -54,42 +54,6 @@ 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> = {
@ -108,9 +72,6 @@ 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 = {
@ -122,9 +83,6 @@ const emptyForm: ServerForm = {
auth_type: "none",
auth_value: "",
enabled: true,
plaintext_env: "",
encrypted_env: "",
http_headers: "",
};
export default function MCPServers() {
@ -197,21 +155,6 @@ 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,
@ -221,9 +164,6 @@ 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);
@ -240,25 +180,10 @@ 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),
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;
? JSON.stringify({ command: form.command, args: form.args.split(/\s+/).filter(Boolean) })
: "{}";
const url = form.transport_type === "http" ? form.url : "";
@ -271,7 +196,6 @@ 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;
@ -286,7 +210,6 @@ export default function MCPServers() {
auth_type: form.auth_type,
auth_value: form.auth_value || undefined,
enabled: form.enabled,
env_config: envConfig,
};
await createMcpServerCmd(request);
}
@ -552,62 +475,6 @@ 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">