Compare commits

..

No commits in common. "02b97134d50aa122583e3bf62f15927b4bdca7ca" and "95a63e18bf2cff175150fe2b59c54a15f04f2739" have entirely different histories.

13 changed files with 24 additions and 2436 deletions

File diff suppressed because it is too large Load Diff

View File

@ -57,9 +57,6 @@ cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings
# Rust quick type check (no linking)
cargo check --manifest-path src-tauri/Cargo.toml
# Frontend linting
npx eslint . --max-warnings 0
```
### System Prerequisites (Linux/Fedora)
@ -119,8 +116,6 @@ All command handlers receive `State<'_, AppState>` as a Tauri-injected parameter
**Database encryption**: `cfg!(debug_assertions)` → plain SQLite; release → SQLCipher AES-256. Key from `TFTSR_DB_KEY` env var (defaults to a dev placeholder). DB path from `TFTSR_DATA_DIR` or platform data dir.
**Credential encryption**: API keys stored in `AppSettings` are encrypted using AES-256-GCM via the `aes-gcm` crate. The encryption key is derived from `TFTSR_ENCRYPTION_KEY` env var. Credentials are encrypted on save and decrypted on load. See `commands/system.rs::save_settings()` for implementation.
### Frontend (React / TypeScript)
**IPC layer**: All Tauri `invoke()` calls are in `src/lib/tauriCommands.ts`. Every command has a typed wrapper function (e.g., `createIssueCmd`, `chatMessageCmd`). This is the single source of truth for the frontend's API surface.

View File

@ -325,3 +325,7 @@ Override with the `TFTSR_DATA_DIR` environment variable.
| 12 | Release Packaging | ✅ linux/amd64 · linux/arm64 (native) · windows/amd64 |
---
## License
Private — internal tooling. All rights reserved.

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,34 +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::*;
@ -371,7 +328,6 @@ mod tests {
auth_type: "none".to_string(),
auth_value: None,
enabled: true,
env_config: None,
}
}
@ -406,7 +362,6 @@ mod tests {
auth_type: None,
auth_value: None,
enabled: None,
env_config: None,
},
)
.unwrap();
@ -430,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();
@ -525,127 +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,134 +1,18 @@
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.
/// Validates environment variable names to block known privilege escalation vectors.
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}"
));
}
// Validate env var names to block dangerous variables that could be used for privilege escalation
const DANGEROUS_ENV_VARS: &[&str] = &[
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
];
for key in env.keys() {
let upper_key = key.to_uppercase();
if DANGEROUS_ENV_VARS.contains(&upper_key.as_str()) {
return Err(format!(
"Dangerous environment variable '{key}' is not allowed for security reasons. \
This variable could be used for privilege escalation attacks."
));
}
}
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}"))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rejects_relative_path() {
let result = build_stdio_transport("./mcp-server", &[], HashMap::new());
assert!(result.is_err());
if let Err(e) = result {
assert!(e.contains("absolute path"));
}
}
#[test]
fn test_rejects_dangerous_env_vars() {
let dangerous_vars = vec![
"LD_PRELOAD",
"LD_LIBRARY_PATH",
"DYLD_INSERT_LIBRARIES",
"DYLD_LIBRARY_PATH",
"DYLD_FRAMEWORK_PATH",
"DYLD_FALLBACK_LIBRARY_PATH",
];
for var in dangerous_vars {
let mut env = HashMap::new();
env.insert(var.to_string(), "malicious.so".to_string());
let result = build_stdio_transport("/usr/bin/test", &[], env);
assert!(result.is_err(), "Should reject {}", var);
if let Err(err) = result {
assert!(
err.contains("Dangerous environment variable"),
"Error should mention dangerous variable: {}",
err
);
}
}
}
#[test]
fn test_rejects_dangerous_env_vars_case_insensitive() {
let mut env = HashMap::new();
env.insert("ld_preload".to_string(), "malicious.so".to_string());
let result = build_stdio_transport("/usr/bin/test", &[], env);
assert!(result.is_err());
if let Err(err) = result {
assert!(err.contains("Dangerous environment variable"));
}
}
#[test]
fn test_allows_safe_env_vars() {
// Test that safe env vars pass validation (validation happens before spawn)
let safe_vars = vec![
("DEBUG", "1"),
("API_KEY", "secret123"),
("PATH", "/usr/bin"),
("HOME", "/home/user"),
("GITHUB_PERSONAL_ACCESS_TOKEN", "ghp_token"),
("LOG_LEVEL", "info"),
];
for (key, value) in safe_vars {
let mut env = HashMap::new();
env.insert(key.to_string(), value.to_string());
// This will fail to spawn since /usr/bin/nonexistent doesn't exist,
// but if validation passed, error won't mention "Dangerous environment variable"
let result = build_stdio_transport("/usr/bin/nonexistent", &[], env);
// Validation passes (doesn't reject env var), spawn fails (command doesn't exist)
if let Err(err) = result {
assert!(
!err.contains("Dangerous environment variable"),
"Should not reject safe env var '{}', got: {}",
key,
err
);
}
}
}
}

View File

@ -1,49 +0,0 @@
export const INCIDENT_RESPONSE_FRAMEWORK = `
---
## INCIDENT RESPONSE METHODOLOGY
Follow this structured framework for every triage conversation. Each phase must be completed with evidence before advancing.
### Phase 1: Detection & Evidence Gathering
- **Do NOT propose fixes** until the problem is fully understood
- Gather: error messages, timestamps, affected systems, scope of impact, recent changes
- Ask: "What changed? When did it start? Who/what is affected? What has been tried?"
- Record all evidence with UTC timestamps
- Establish a clear problem statement before proceeding
### Phase 2: Diagnosis & Hypothesis Testing
- Apply the scientific method: form hypotheses, test them with evidence
- **The 3-Fix Rule**: If you cannot confidently identify the root cause after 3 hypotheses, STOP and reassess your assumptions you may be looking at the wrong system or the wrong layer
- Check the most common causes first (Occam's Razor): DNS, certificates, disk space, permissions, recent deployments
- Differentiate between symptoms and causes treat causes, not symptoms
- Use binary search to narrow scope: which component, which layer, which change
### Phase 3: Root Cause Analysis with 5-Whys
- Each "Why" must be backed by evidence, not speculation
- If you cannot provide evidence for a "Why", state what investigation is needed to confirm
- Look for systemic issues, not just proximate causes
- The root cause should explain ALL observed symptoms, not just some
- Common root cause categories: configuration drift, capacity exhaustion, dependency failure, race condition, human error in process
### Phase 4: Resolution & Prevention
- **Immediate fix**: What stops the bleeding right now? (rollback, restart, failover)
- **Permanent fix**: What prevents recurrence? (code fix, config change, automation)
- **Runbook update**: Document the fix for future oncall engineers
- Verify the fix resolves ALL symptoms, not just the primary one
- Monitor for regression after applying the fix
### Phase 5: Post-Incident Review
- Calculate incident metrics: MTTD (detect), MTTA (acknowledge), MTTR (resolve)
- Conduct blameless post-mortem focused on systems and processes
- Identify action items with owners and due dates
- Categories: monitoring gaps, process improvements, technical debt, training needs
- Ask: "What would have prevented this? What would have detected it faster? What would have resolved it faster?"
### Communication Practices
- State your current phase explicitly (e.g., "We are in Phase 2: Diagnosis")
- Summarize findings at each phase transition
- Flag assumptions clearly: "ASSUMPTION: ..." vs "CONFIRMED: ..."
- When advancing the Why level, explicitly state the evidence chain
`;

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="text"
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={editServer ? "Leave blank to keep existing values" : "API_KEY=secret TOKEN=xyz"}
/>
{editServer && (
<p className="text-xs text-yellow-600 dark:text-yellow-400 mt-1">
Encrypted values are stored securely and never displayed. Leave blank to preserve existing 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">