use std::collections::HashMap;
use std::sync::Arc;
use indexmap::IndexMap;
use rmcp::handler::server::router::tool::ToolRouter;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::model::*;
use rmcp::schemars;
use rmcp::service::RequestContext;
use rmcp::{ErrorData as McpError, RoleServer, ServerHandler};
use rmcp::{tool, tool_router};
use tokio::sync::{OnceCell, RwLock};
use crate::config::{Config, McpConfig, McpTool, SecretConfig};
use crate::secret_resolver::resolve_secrets_batch;
use crate::temp_file_secrets::create_ephemeral_secret_file;
const MAX_OUTPUT_BYTES: usize = 1024 * 1024;
const PER_STREAM_LIMIT: usize = (MAX_OUTPUT_BYTES / 2) + 1;
const DEFAULT_EXEC_TIMEOUT_SECS: u64 = 300;
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GetSecretParams {
pub name: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct ExecParams {
pub command: Vec<String>,
}
#[derive(Clone)]
pub struct FnoxMcpServer {
config: Arc<Config>,
profile: Arc<String>,
mcp_config: Arc<McpConfig>,
profile_secrets: Arc<IndexMap<String, SecretConfig>>,
cache: Arc<RwLock<HashMap<String, Option<String>>>>,
resolved: Arc<OnceCell<()>>,
tool_router: ToolRouter<FnoxMcpServer>,
}
#[tool_router]
impl FnoxMcpServer {
pub fn new(
config: Config,
profile: String,
mcp_config: McpConfig,
profile_secrets: IndexMap<String, SecretConfig>,
) -> Self {
Self {
config: Arc::new(config),
profile: Arc::new(profile),
mcp_config: Arc::new(mcp_config),
profile_secrets: Arc::new(profile_secrets),
cache: Arc::new(RwLock::new(HashMap::new())),
resolved: Arc::new(OnceCell::new()),
tool_router: Self::tool_router(),
}
}
async fn ensure_resolved(&self) -> Result<(), McpError> {
let config = self.config.clone();
let profile = self.profile.clone();
let profile_secrets = self.profile_secrets.clone();
let cache = self.cache.clone();
self.resolved
.get_or_try_init(|| async {
let env_secrets: IndexMap<String, SecretConfig> = profile_secrets
.iter()
.filter(|(_, sc)| sc.env)
.map(|(k, v)| (k.clone(), v.clone()))
.collect();
let resolved = resolve_secrets_batch(&config, &profile, &env_secrets)
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to resolve secrets: {e}"), None)
})?;
let mut cache = cache.write().await;
for (key, value) in resolved {
cache.insert(key, value);
}
Ok(())
})
.await?;
Ok(())
}
async fn resolve_single(&self, name: &str) -> Result<Option<String>, McpError> {
{
let cache = self.cache.read().await;
if let Some(v) = cache.get(name) {
return Ok(v.clone());
}
}
let secret_config = match self.profile_secrets.get(name) {
Some(sc) => sc,
None => return Ok(None),
};
let single: IndexMap<String, SecretConfig> = [(name.to_string(), secret_config.clone())]
.into_iter()
.collect();
let resolved = resolve_secrets_batch(&self.config, &self.profile, &single)
.await
.map_err(|e| {
McpError::internal_error(format!("Failed to resolve secret '{name}': {e}"), None)
})?;
let value = resolved.into_iter().next().and_then(|(_, v)| v);
let mut cache = self.cache.write().await;
if let Some(existing) = cache.get(name) {
return Ok(existing.clone());
}
cache.insert(name.to_string(), value.clone());
Ok(value)
}
fn create_secret_file(
key: &str,
value: &str,
temp_files: &mut Vec<tempfile::NamedTempFile>,
) -> Result<String, McpError> {
let temp_file = create_ephemeral_secret_file(key, value).map_err(|e| {
McpError::internal_error(
format!("Failed to create temp file for secret '{key}': {e}"),
None,
)
})?;
let file_path = temp_file.path().to_string_lossy().to_string();
temp_files.push(temp_file);
Ok(file_path)
}
#[tool(description = "Get a secret value by name from the fnox configuration")]
async fn get_secret(
&self,
Parameters(params): Parameters<GetSecretParams>,
) -> Result<CallToolResult, McpError> {
if !self.mcp_config.tools().contains(&McpTool::GetSecret) {
return Err(McpError::invalid_request(
"Tool 'get_secret' is not enabled in this configuration",
None,
));
}
if let Some(sc) = self.profile_secrets.get(¶ms.name)
&& sc.as_file
{
return Err(McpError::invalid_request(
format!(
"Secret '{}' is configured with as_file=true and can only be used via the exec tool",
params.name
),
None,
));
}
self.ensure_resolved().await?;
{
let cache = self.cache.read().await;
if let Some(cached) = cache.get(¶ms.name) {
return match cached {
Some(value) => Ok(CallToolResult::success(vec![Content::text(value.clone())])),
None => Err(McpError::invalid_request(
format!(
"Secret '{}' has no value (it may be optional and absent)",
params.name
),
None,
)),
};
}
}
if let Some(secret_config) = self.profile_secrets.get(¶ms.name) {
if !secret_config.env {
return match self.resolve_single(¶ms.name).await? {
Some(value) => Ok(CallToolResult::success(vec![Content::text(value)])),
None => Err(McpError::invalid_request(
format!(
"Secret '{}' has no value (it may be optional and absent)",
params.name
),
None,
)),
};
}
Err(McpError::invalid_request(
format!(
"Secret '{}' has no value (it may be optional and absent)",
params.name
),
None,
))
} else {
Err(McpError::invalid_params(
format!("Secret '{}' not found in configuration", params.name),
None,
))
}
}
#[tool(
description = "Execute a command with secrets injected as environment variables. Returns the command's stdout and stderr."
)]
async fn exec(
&self,
Parameters(params): Parameters<ExecParams>,
) -> Result<CallToolResult, McpError> {
if !self.mcp_config.tools().contains(&McpTool::Exec) {
return Err(McpError::invalid_request(
"Tool 'exec' is not enabled in this configuration",
None,
));
}
if params.command.is_empty() || params.command[0].is_empty() {
return Err(McpError::invalid_params("Command must not be empty", None));
}
self.ensure_resolved().await?;
let env_vars: Vec<(String, String)> = {
let cache = self.cache.read().await;
cache
.iter()
.filter(|(key, _)| {
self.profile_secrets
.get(key.as_str())
.is_some_and(|sc| sc.env)
})
.filter_map(|(k, v)| v.as_ref().map(|val| (k.clone(), val.clone())))
.collect()
};
let mut _exec_temp_files = Vec::new();
let cmd_name = ¶ms.command[0];
#[cfg(windows)]
let cmd_path = which::which(cmd_name).unwrap_or_else(|_| cmd_name.into());
#[cfg(not(windows))]
let cmd_path = cmd_name;
let mut cmd = tokio::process::Command::new(cmd_path);
if params.command.len() > 1 {
cmd.args(¶ms.command[1..]);
}
for (key, value) in &env_vars {
if let Some(sc) = self.profile_secrets.get(key.as_str())
&& sc.as_file
{
let path = Self::create_secret_file(key, value, &mut _exec_temp_files)?;
cmd.env(key, &path);
} else {
cmd.env(key, value);
}
}
for (key, secret_config) in self.profile_secrets.iter() {
if !secret_config.env {
cmd.env_remove(key);
}
}
cmd.stdin(std::process::Stdio::null());
cmd.stdout(std::process::Stdio::piped());
cmd.stderr(std::process::Stdio::piped());
cmd.kill_on_drop(true);
let timeout_secs = self
.mcp_config
.exec_timeout_secs
.unwrap_or(DEFAULT_EXEC_TIMEOUT_SECS);
let mut child = cmd.spawn().map_err(|e| {
McpError::internal_error(
format!("Failed to execute command '{}': {e}", params.command[0]),
None,
)
})?;
let mut child_stdout = child.stdout.take();
let mut child_stderr = child.stderr.take();
let collect_bounded = async {
use tokio::io::AsyncReadExt;
let per_stream_limit = PER_STREAM_LIMIT;
let stdout_fut = async {
let mut buf = Vec::with_capacity(per_stream_limit.min(65536));
if let Some(ref mut out) = child_stdout {
out.take(per_stream_limit as u64)
.read_to_end(&mut buf)
.await
.ok();
}
drop(child_stdout);
buf
};
let stderr_fut = async {
let mut buf = Vec::with_capacity(per_stream_limit.min(65536));
if let Some(ref mut err) = child_stderr {
err.take(per_stream_limit as u64)
.read_to_end(&mut buf)
.await
.ok();
}
drop(child_stderr);
buf
};
let (stdout_buf, stderr_buf) = tokio::join!(stdout_fut, stderr_fut);
let status = child.wait().await;
(stdout_buf, stderr_buf, status)
};
let (stdout_buf, stderr_buf, status) = tokio::time::timeout(
std::time::Duration::from_secs(timeout_secs),
collect_bounded,
)
.await
.map_err(|_| {
McpError::internal_error(
format!(
"Command '{}' timed out after {timeout_secs}s",
params.command[0]
),
None,
)
})?;
let status = status.map_err(|e| {
McpError::internal_error(
format!("Failed to wait for command '{}': {e}", params.command[0]),
None,
)
})?;
let per_stream_cap = PER_STREAM_LIMIT;
let total_collected = stdout_buf.len() + stderr_buf.len();
let stdout_truncated = stdout_buf.len() >= per_stream_cap;
let stderr_truncated = stderr_buf.len() >= per_stream_cap;
let truncated = stdout_truncated || stderr_truncated || total_collected > MAX_OUTPUT_BYTES;
let display_limit = PER_STREAM_LIMIT - 1;
let stdout_raw =
String::from_utf8_lossy(&stdout_buf[..stdout_buf.len().min(display_limit)]);
let stderr_raw =
String::from_utf8_lossy(&stderr_buf[..stderr_buf.len().min(display_limit)]);
let (stdout, stderr) = if self.mcp_config.redact_output() {
(
redact_secrets(&stdout_raw, &env_vars)?,
redact_secrets(&stderr_raw, &env_vars)?,
)
} else {
(stdout_raw.to_string(), stderr_raw.to_string())
};
let mut parts = Vec::new();
if !stdout.is_empty() {
parts.push(stdout);
}
if !stderr.is_empty() {
parts.push(format!("[stderr]\n{stderr}"));
}
let exit_code = status.code().unwrap_or(-1);
if !status.success() || parts.is_empty() {
parts.push(format!("[exit code: {exit_code}]"));
}
if truncated {
if stdout_truncated || stderr_truncated {
let stream_limit = per_stream_cap - 1;
parts.push(format!(
"[output truncated: per-stream limit of {stream_limit} bytes exceeded (total collected: {total_collected} bytes)]"
));
} else {
parts.push(format!(
"[output truncated: {total_collected} bytes exceeded {MAX_OUTPUT_BYTES} byte limit]"
));
}
}
let text = parts.join("\n");
if status.success() {
Ok(CallToolResult::success(vec![Content::text(text)]))
} else {
Ok(CallToolResult::error(vec![Content::text(text)]))
}
}
}
const MIN_REDACT_LENGTH: usize = 3;
fn redact_secrets(text: &str, secret_values: &[(String, String)]) -> Result<String, McpError> {
let values: Vec<&str> = secret_values
.iter()
.map(|(_, v)| v.trim())
.filter(|v| !v.is_empty() && v.len() >= MIN_REDACT_LENGTH)
.collect();
if values.is_empty() {
return Ok(text.to_string());
}
let ac = aho_corasick::AhoCorasick::builder()
.match_kind(aho_corasick::MatchKind::LeftmostLongest)
.build(&values)
.map_err(|e| {
McpError::internal_error(
format!(
"Failed to build redaction filter: {e}. Refusing to return unredacted output."
),
None,
)
})?;
Ok(ac.replace_all(text, &vec!["[REDACTED]"; values.len()]))
}
impl ServerHandler for FnoxMcpServer {
fn get_info(&self) -> ServerInfo {
let tools = self.mcp_config.tools();
let has_get_secret = tools.contains(&McpTool::GetSecret);
let has_exec = tools.contains(&McpTool::Exec);
let instructions = if has_get_secret && has_exec {
"fnox MCP server — a session-scoped secret broker. \
Use get_secret to retrieve individual secrets, \
or exec to run commands with secrets injected as environment variables."
} else if has_get_secret {
"fnox MCP server — a session-scoped secret broker. \
Use get_secret to retrieve individual secrets."
} else if has_exec {
"fnox MCP server — a session-scoped secret broker. \
Use exec to run commands with secrets injected as environment variables."
} else {
"fnox MCP server — no tools are currently enabled."
};
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("fnox-mcp", env!("CARGO_PKG_VERSION")))
.with_protocol_version(ProtocolVersion::V_2024_11_05)
.with_instructions(instructions.to_string())
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, McpError> {
let all_tools = self.tool_router.list_all();
let tools = self.mcp_config.tools();
let enabled: Vec<&str> = tools.iter().map(|t| t.tool_name()).collect();
let filtered = all_tools
.into_iter()
.filter(|t| enabled.contains(&t.name.as_ref()))
.collect();
Ok(ListToolsResult {
tools: filtered,
meta: None,
next_cursor: None,
})
}
fn get_tool(&self, name: &str) -> Option<Tool> {
let tools = self.mcp_config.tools();
let enabled: Vec<&str> = tools.iter().map(|t| t.tool_name()).collect();
if !enabled.contains(&name) {
return None;
}
self.tool_router.get(name).cloned()
}
async fn call_tool(
&self,
request: CallToolRequestParams,
context: RequestContext<RoleServer>,
) -> Result<CallToolResult, McpError> {
let tools = self.mcp_config.tools();
let enabled: Vec<&str> = tools.iter().map(|t| t.tool_name()).collect();
if !enabled.contains(&request.name.as_ref()) {
return Err(McpError::invalid_request(
format!(
"Tool '{}' is not enabled in this configuration",
request.name
),
None,
));
}
let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
self.tool_router.call(tcc).await
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn redact_replaces_secret_values() {
let secrets = vec![
("API_KEY".into(), "sk-abc123".into()),
("DB_PASS".into(), "hunter2".into()),
];
let input = "API_KEY=sk-abc123\nDB_PASS=hunter2\nOK=public";
let result = redact_secrets(input, &secrets).unwrap();
assert_eq!(result, "API_KEY=[REDACTED]\nDB_PASS=[REDACTED]\nOK=public");
}
#[test]
fn redact_longest_match_wins() {
let secrets = vec![
("SHORT".into(), "abc".into()),
("LONG".into(), "abcdef".into()),
];
let input = "value is abcdef";
let result = redact_secrets(input, &secrets).unwrap();
assert_eq!(result, "value is [REDACTED]");
}
#[test]
fn redact_skips_empty_and_short_secrets() {
let secrets = vec![
("EMPTY".into(), "".into()),
("SPACES".into(), " ".into()),
("SHORT".into(), "ab".into()),
("REAL".into(), "secret".into()),
];
let input = "the secret has ab in it";
let result = redact_secrets(input, &secrets).unwrap();
assert_eq!(result, "the [REDACTED] has ab in it");
}
#[test]
fn redact_no_secrets_returns_unchanged() {
let input = "nothing to redact here";
let result = redact_secrets(input, &[]).unwrap();
assert_eq!(result, input);
}
#[test]
fn redact_multiple_occurrences() {
let secrets = vec![("TOKEN".into(), "xyz789".into())];
let input = "xyz789 and xyz789 again";
let result = redact_secrets(input, &secrets).unwrap();
assert_eq!(result, "[REDACTED] and [REDACTED] again");
}
#[test]
fn redact_does_not_corrupt_markers() {
let secrets = vec![
("LONG".into(), "sk-abc123".into()),
("OVERLAP".into(), "DACT".into()),
];
let input = "sk-abc123 key";
let result = redact_secrets(input, &secrets).unwrap();
assert_eq!(result, "[REDACTED] key");
}
}