Merge pull request 'bug/mcp-env-vars-support' (#61) from bug/mcp-env-vars-support into master
All checks were successful
Auto Tag / autotag (push) Successful in 6s
Auto Tag / wiki-sync (push) Successful in 8s
Test / rust-fmt-check (push) Successful in 1m23s
Test / frontend-typecheck (push) Successful in 1m30s
Auto Tag / changelog (push) Successful in 1m32s
Test / frontend-tests (push) Successful in 1m36s
Test / rust-clippy (push) Successful in 3m40s
Test / rust-tests (push) Successful in 4m58s
Auto Tag / build-macos-arm64 (push) Successful in 5m31s
Auto Tag / build-linux-amd64 (push) Successful in 8m44s
Auto Tag / build-windows-amd64 (push) Successful in 10m45s
Auto Tag / build-linux-arm64 (push) Successful in 10m54s
All checks were successful
Auto Tag / autotag (push) Successful in 6s
Auto Tag / wiki-sync (push) Successful in 8s
Test / rust-fmt-check (push) Successful in 1m23s
Test / frontend-typecheck (push) Successful in 1m30s
Auto Tag / changelog (push) Successful in 1m32s
Test / frontend-tests (push) Successful in 1m36s
Test / rust-clippy (push) Successful in 3m40s
Test / rust-tests (push) Successful in 4m58s
Auto Tag / build-macos-arm64 (push) Successful in 5m31s
Auto Tag / build-linux-amd64 (push) Successful in 8m44s
Auto Tag / build-windows-amd64 (push) Successful in 10m45s
Auto Tag / build-linux-arm64 (push) Successful in 10m54s
Reviewed-on: #61
This commit is contained in:
commit
02b97134d5
1834
2026-hackathon_AgenticFeature.md
Normal file
1834
2026-hackathon_AgenticFeature.md
Normal file
File diff suppressed because it is too large
Load Diff
@ -57,6 +57,9 @@ cargo clippy --manifest-path src-tauri/Cargo.toml -- -D warnings
|
|||||||
|
|
||||||
# Rust quick type check (no linking)
|
# Rust quick type check (no linking)
|
||||||
cargo check --manifest-path src-tauri/Cargo.toml
|
cargo check --manifest-path src-tauri/Cargo.toml
|
||||||
|
|
||||||
|
# Frontend linting
|
||||||
|
npx eslint . --max-warnings 0
|
||||||
```
|
```
|
||||||
|
|
||||||
### System Prerequisites (Linux/Fedora)
|
### System Prerequisites (Linux/Fedora)
|
||||||
@ -116,6 +119,8 @@ 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.
|
**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)
|
### 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.
|
**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.
|
||||||
|
|||||||
@ -325,7 +325,3 @@ Override with the `TFTSR_DATA_DIR` environment variable.
|
|||||||
| 12 | Release Packaging | ✅ linux/amd64 · linux/arm64 (native) · windows/amd64 |
|
| 12 | Release Packaging | ✅ linux/amd64 · linux/arm64 (native) · windows/amd64 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## License
|
|
||||||
|
|
||||||
Private — internal tooling. All rights reserved.
|
|
||||||
|
|||||||
@ -283,6 +283,10 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
|
|||||||
FROM image_attachments ia
|
FROM image_attachments ia
|
||||||
JOIN issues i ON i.id = ia.issue_id;",
|
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 {
|
for (name, sql) in migrations {
|
||||||
@ -1233,4 +1237,39 @@ mod tests {
|
|||||||
assert_eq!(count, 1, "{migration} should be recorded exactly once");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,17 +7,26 @@ use crate::mcp::models::{McpResource, McpTool};
|
|||||||
/// Live connection to an MCP server.
|
/// Live connection to an MCP server.
|
||||||
pub type McpConnection = RunningService<RoleClient, ()>;
|
pub type McpConnection = RunningService<RoleClient, ()>;
|
||||||
|
|
||||||
/// Connect to a stdio MCP server.
|
/// Connect to a stdio MCP server with optional environment variables.
|
||||||
pub async fn connect_stdio(command: &str, args: &[String]) -> Result<McpConnection, String> {
|
pub async fn connect_stdio(
|
||||||
let transport = crate::mcp::transport::stdio::build_stdio_transport(command, args)?;
|
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)?;
|
||||||
().serve(transport)
|
().serve(transport)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("MCP stdio connection failed: {e}"))
|
.map_err(|e| format!("MCP stdio connection failed: {e}"))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Connect to an HTTP MCP server.
|
/// Connect to an HTTP MCP server with optional custom headers.
|
||||||
pub async fn connect_http(url: &str, auth_header: Option<&str>) -> Result<McpConnection, String> {
|
pub async fn connect_http(
|
||||||
let transport = crate::mcp::transport::http::build_http_transport(url, auth_header);
|
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);
|
||||||
().serve(transport)
|
().serve(transport)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| format!("MCP HTTP connection failed: {e}"))
|
.map_err(|e| format!("MCP HTTP connection failed: {e}"))
|
||||||
|
|||||||
@ -5,7 +5,8 @@ use tracing::{info, warn};
|
|||||||
use crate::mcp::client::{connect_http, connect_stdio, list_resources, list_tools, McpConnection};
|
use crate::mcp::client::{connect_http, connect_stdio, list_resources, list_tools, McpConnection};
|
||||||
use crate::mcp::models::McpServer;
|
use crate::mcp::models::McpServer;
|
||||||
use crate::mcp::store::{
|
use crate::mcp::store::{
|
||||||
get_server_auth_value, list_servers, replace_resources, replace_tools, update_discovery_status,
|
get_server_auth_value, get_server_env_config, list_servers, replace_resources, replace_tools,
|
||||||
|
update_discovery_status,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Discover a single MCP server: connect, list tools/resources, persist.
|
/// Discover a single MCP server: connect, list tools/resources, persist.
|
||||||
@ -55,11 +56,49 @@ async fn discover_server_inner(
|
|||||||
.collect()
|
.collect()
|
||||||
})
|
})
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
connect_stdio(command, &args).await?
|
|
||||||
|
// 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?
|
||||||
}
|
}
|
||||||
"http" => {
|
"http" => {
|
||||||
let auth_header = auth_value.as_deref();
|
let auth_header = auth_value.as_deref();
|
||||||
connect_http(&server.url, auth_header).await?
|
|
||||||
|
// 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?
|
||||||
}
|
}
|
||||||
other => return Err(format!("Unknown transport type: {other}")),
|
other => return Err(format!("Unknown transport type: {other}")),
|
||||||
};
|
};
|
||||||
|
|||||||
@ -18,6 +18,8 @@ pub struct McpServer {
|
|||||||
pub discovery_error: Option<String>,
|
pub discovery_error: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
pub updated_at: String,
|
pub updated_at: String,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub env_config: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -64,6 +66,8 @@ pub struct CreateMcpServerRequest {
|
|||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub auth_value: Option<String>,
|
pub auth_value: Option<String>,
|
||||||
pub enabled: bool,
|
pub enabled: bool,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub env_config: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
@ -82,4 +86,6 @@ pub struct UpdateMcpServerRequest {
|
|||||||
pub auth_value: Option<String>,
|
pub auth_value: Option<String>,
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub enabled: Option<bool>,
|
pub enabled: Option<bool>,
|
||||||
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
|
pub env_config: Option<String>,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -15,10 +15,15 @@ pub fn create_server(conn: &Connection, req: &CreateMcpServerRequest) -> Result<
|
|||||||
_ => None,
|
_ => 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(
|
conn.execute(
|
||||||
"INSERT INTO mcp_servers
|
"INSERT INTO mcp_servers
|
||||||
(id, name, url, transport_type, transport_config, auth_type, auth_value, enabled, created_at, updated_at)
|
(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, ?9)",
|
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?10)",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
id,
|
id,
|
||||||
req.name,
|
req.name,
|
||||||
@ -28,6 +33,7 @@ pub fn create_server(conn: &Connection, req: &CreateMcpServerRequest) -> Result<
|
|||||||
req.auth_type,
|
req.auth_type,
|
||||||
encrypted_auth,
|
encrypted_auth,
|
||||||
req.enabled as i32,
|
req.enabled as i32,
|
||||||
|
encrypted_env,
|
||||||
now,
|
now,
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
@ -41,7 +47,7 @@ pub fn get_server(conn: &Connection, id: &str) -> Result<Option<McpServer>, Stri
|
|||||||
.query_row(
|
.query_row(
|
||||||
"SELECT id, name, url, transport_type, transport_config, auth_type, auth_value,
|
"SELECT id, name, url, transport_type, transport_config, auth_type, auth_value,
|
||||||
enabled, last_discovered_at, discovery_status, discovery_error,
|
enabled, last_discovered_at, discovery_status, discovery_error,
|
||||||
created_at, updated_at
|
created_at, updated_at, env_config
|
||||||
FROM mcp_servers WHERE id = ?1",
|
FROM mcp_servers WHERE id = ?1",
|
||||||
[id],
|
[id],
|
||||||
|row| {
|
|row| {
|
||||||
@ -59,6 +65,7 @@ pub fn get_server(conn: &Connection, id: &str) -> Result<Option<McpServer>, Stri
|
|||||||
discovery_error: row.get(10)?,
|
discovery_error: row.get(10)?,
|
||||||
created_at: row.get(11)?,
|
created_at: row.get(11)?,
|
||||||
updated_at: row.get(12)?,
|
updated_at: row.get(12)?,
|
||||||
|
env_config: row.get(13)?,
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@ -72,7 +79,7 @@ pub fn list_servers(conn: &Connection) -> Result<Vec<McpServer>, String> {
|
|||||||
.prepare(
|
.prepare(
|
||||||
"SELECT id, name, url, transport_type, transport_config, auth_type, auth_value,
|
"SELECT id, name, url, transport_type, transport_config, auth_type, auth_value,
|
||||||
enabled, last_discovered_at, discovery_status, discovery_error,
|
enabled, last_discovered_at, discovery_status, discovery_error,
|
||||||
created_at, updated_at
|
created_at, updated_at, env_config
|
||||||
FROM mcp_servers ORDER BY created_at ASC",
|
FROM mcp_servers ORDER BY created_at ASC",
|
||||||
)
|
)
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
@ -93,6 +100,7 @@ pub fn list_servers(conn: &Connection) -> Result<Vec<McpServer>, String> {
|
|||||||
discovery_error: row.get(10)?,
|
discovery_error: row.get(10)?,
|
||||||
created_at: row.get(11)?,
|
created_at: row.get(11)?,
|
||||||
updated_at: row.get(12)?,
|
updated_at: row.get(12)?,
|
||||||
|
env_config: row.get(13)?,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
@ -116,11 +124,17 @@ pub fn update_server(
|
|||||||
None => existing.auth_value.clone(),
|
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(
|
conn.execute(
|
||||||
"UPDATE mcp_servers SET
|
"UPDATE mcp_servers SET
|
||||||
name = ?1, url = ?2, transport_type = ?3, transport_config = ?4,
|
name = ?1, url = ?2, transport_type = ?3, transport_config = ?4,
|
||||||
auth_type = ?5, auth_value = ?6, enabled = ?7, updated_at = ?8
|
auth_type = ?5, auth_value = ?6, enabled = ?7, env_config = ?8, updated_at = ?9
|
||||||
WHERE id = ?9",
|
WHERE id = ?10",
|
||||||
rusqlite::params![
|
rusqlite::params![
|
||||||
req.name.as_deref().unwrap_or(&existing.name),
|
req.name.as_deref().unwrap_or(&existing.name),
|
||||||
req.url.as_deref().unwrap_or(&existing.url),
|
req.url.as_deref().unwrap_or(&existing.url),
|
||||||
@ -135,6 +149,7 @@ pub fn update_server(
|
|||||||
req.enabled
|
req.enabled
|
||||||
.map(|b| b as i32)
|
.map(|b| b as i32)
|
||||||
.unwrap_or(existing.enabled as i32),
|
.unwrap_or(existing.enabled as i32),
|
||||||
|
new_encrypted_env,
|
||||||
now,
|
now,
|
||||||
id,
|
id,
|
||||||
],
|
],
|
||||||
@ -308,6 +323,34 @@ pub fn get_resource_count(conn: &Connection, server_id: &str) -> Result<usize, S
|
|||||||
.map_err(|e| e.to_string())
|
.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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@ -328,6 +371,7 @@ mod tests {
|
|||||||
auth_type: "none".to_string(),
|
auth_type: "none".to_string(),
|
||||||
auth_value: None,
|
auth_value: None,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
env_config: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -362,6 +406,7 @@ mod tests {
|
|||||||
auth_type: None,
|
auth_type: None,
|
||||||
auth_value: None,
|
auth_value: None,
|
||||||
enabled: None,
|
enabled: None,
|
||||||
|
env_config: None,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@ -385,6 +430,7 @@ mod tests {
|
|||||||
auth_type: "bearer".to_string(),
|
auth_type: "bearer".to_string(),
|
||||||
auth_value: Some("super-secret-token".to_string()),
|
auth_value: Some("super-secret-token".to_string()),
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
env_config: None,
|
||||||
};
|
};
|
||||||
let server = create_server(&conn, &req).unwrap();
|
let server = create_server(&conn, &req).unwrap();
|
||||||
|
|
||||||
@ -479,4 +525,127 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 0, "cascade delete should clear mcp_tools");
|
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");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,17 +1,32 @@
|
|||||||
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
|
use rmcp::transport::streamable_http_client::StreamableHttpClientTransportConfig;
|
||||||
use rmcp::transport::StreamableHttpClientTransport;
|
use rmcp::transport::StreamableHttpClientTransport;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// Build an HTTP (Streamable HTTP) transport from a URL.
|
/// Build an HTTP (Streamable HTTP) transport from a URL with optional custom headers.
|
||||||
/// Optionally attaches an Authorization bearer token.
|
/// 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(
|
pub fn build_http_transport(
|
||||||
url: &str,
|
url: &str,
|
||||||
auth_header: Option<&str>,
|
auth_header: Option<&str>,
|
||||||
|
custom_headers: HashMap<String, String>,
|
||||||
) -> impl rmcp::transport::Transport<rmcp::RoleClient> {
|
) -> 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 {
|
let config = match auth_header {
|
||||||
Some(token) => StreamableHttpClientTransportConfig::with_uri(Arc::from(url))
|
Some(token) => StreamableHttpClientTransportConfig::with_uri(Arc::from(url))
|
||||||
.auth_header(token.to_string()),
|
.auth_header(token.to_string()),
|
||||||
None => StreamableHttpClientTransportConfig::with_uri(Arc::from(url)),
|
None => StreamableHttpClientTransportConfig::with_uri(Arc::from(url)),
|
||||||
};
|
};
|
||||||
|
|
||||||
StreamableHttpClientTransport::from_config(config)
|
StreamableHttpClientTransport::from_config(config)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,134 @@
|
|||||||
use rmcp::transport::TokioChildProcess;
|
use rmcp::transport::TokioChildProcess;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::path::Path;
|
use std::path::Path;
|
||||||
use tokio::process::Command;
|
use tokio::process::Command;
|
||||||
|
|
||||||
/// Build a stdio transport from a command path and argument list.
|
/// Build a stdio transport from a command path, argument list, and environment variables.
|
||||||
/// Rejects relative paths to prevent path traversal.
|
/// Rejects relative paths to prevent path traversal.
|
||||||
pub fn build_stdio_transport(command: &str, args: &[String]) -> Result<TokioChildProcess, String> {
|
/// 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> {
|
||||||
if !Path::new(command).is_absolute() {
|
if !Path::new(command).is_absolute() {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"stdio command must be an absolute path, got: {command}"
|
"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);
|
let mut cmd = Command::new(command);
|
||||||
cmd.args(args);
|
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}"))
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
49
src/lib/INCIDENT_RESPONSE_FRAMEWORK.ts
Normal file
49
src/lib/INCIDENT_RESPONSE_FRAMEWORK.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
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
|
||||||
|
`;
|
||||||
@ -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="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 />
|
<Separator />
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user