use std::process::Stdio;
use std::sync::Arc;
use std::time::Duration;
use murk_cli::types::{Murk, Vault};
use rmcp::{
ErrorData as McpError, ServerHandler, ServiceExt,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::{CallToolResult, ContentBlock, Implementation, ServerCapabilities, ServerInfo},
tool, tool_handler, tool_router,
transport::stdio,
};
use schemars::JsonSchema;
use serde::Deserialize;
use tokio::io::AsyncReadExt;
use tokio::process::Command as TokioCommand;
pub struct McpState {
pub vault: Vault,
pub murk: Murk,
pub pubkey: String,
}
#[derive(Clone)]
pub struct MurkMcp {
state: Arc<McpState>,
tool_router: ToolRouter<Self>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct PlanRequest {
#[serde(default)]
tags: Vec<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct GetRequest {
key: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
struct ExecRequest {
only: Vec<String>,
command: Vec<String>,
}
#[tool_router]
impl MurkMcp {
fn new(state: Arc<McpState>, allow_exec: bool) -> Self {
let mut tool_router = Self::tool_router();
if !allow_exec {
tool_router.remove_route("murk_exec");
}
Self { state, tool_router }
}
#[tool(
description = "List the schema (key names, descriptions, examples, tags) of the secrets this grant may read, as JSON. Bounded to the grant's scope; never returns secret values."
)]
async fn murk_plan(
&self,
Parameters(PlanRequest { tags }): Parameters<PlanRequest>,
) -> Result<CallToolResult, McpError> {
let st = &self.state;
let mut plan = murk_cli::agent_plan(&st.vault, &tags);
plan.entries.retain(|e| {
murk_cli::is_agent_key_allowed(&st.vault, &e.key)
&& murk_cli::get_secret(&st.murk, &e.key, &st.pubkey).is_some()
});
match serde_json::to_string_pretty(&plan) {
Ok(json) => Ok(CallToolResult::success(vec![ContentBlock::text(json)])),
Err(e) => Ok(CallToolResult::error(vec![ContentBlock::text(format!(
"failed to render plan: {e}"
))])),
}
}
#[tool(
description = "Read a single secret value by key. Fails closed: keys forbidden by the vault's agent policy or outside this grant's scope return an error, never the value."
)]
async fn murk_get(
&self,
Parameters(GetRequest { key }): Parameters<GetRequest>,
) -> Result<CallToolResult, McpError> {
let st = &self.state;
if let Err(e) = murk_cli::enforce_agent_policy(
&st.vault,
&st.murk,
&st.pubkey,
std::slice::from_ref(&key),
) {
return Ok(CallToolResult::error(vec![ContentBlock::text(
e.to_string(),
)]));
}
match murk_cli::get_secret(&st.murk, &key, &st.pubkey) {
Some(value) => Ok(CallToolResult::success(vec![ContentBlock::text(
value.to_string(),
)])),
None => Ok(CallToolResult::error(vec![ContentBlock::text(format!(
"{key}: not found or outside this grant's scope"
))])),
}
}
#[tool(
description = "Run a command with scoped secrets injected into its environment (no shell), returning captured stdout/stderr and the exit code as JSON. Requires `only` (keys to inject) and `command` (argv). Fails closed on any key outside this grant's scope or the agent policy."
)]
async fn murk_exec(
&self,
Parameters(ExecRequest { only, command }): Parameters<ExecRequest>,
) -> Result<CallToolResult, McpError> {
let st = &self.state;
if command.is_empty() {
return Ok(tool_err("command must not be empty"));
}
if only.is_empty() {
return Ok(tool_err(
"only must name at least one key — murk_exec never runs with an unscoped environment",
));
}
if let Err(e) = murk_cli::enforce_agent_policy(&st.vault, &st.murk, &st.pubkey, &only) {
return Ok(tool_err(&e.to_string()));
}
let mut secrets: Vec<(String, String)> = Vec::with_capacity(only.len());
for key in &only {
let Some(value) = murk_cli::get_secret(&st.murk, key, &st.pubkey) else {
return Ok(tool_err(&format!(
"{key}: not found or outside this grant's scope"
)));
};
if key.is_empty() || key.contains(['=', '\0']) {
return Ok(tool_err(&format!(
"{key}: not a valid environment variable name"
)));
}
if value.contains('\0') {
return Ok(tool_err(&format!(
"{key}: value contains a NUL byte and cannot be injected as an environment variable"
)));
}
secrets.push((key.clone(), value.to_string()));
}
run_command(&command, &secrets).await
}
}
#[tool_handler(router = self.tool_router)]
impl ServerHandler for MurkMcp {
fn get_info(&self) -> ServerInfo {
ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
.with_server_info(Implementation::new("murk-mcp", env!("CARGO_PKG_VERSION")))
.with_instructions(
"murk secrets for AI agents, bounded to this grant's scope. Use murk_plan \
for the value-free schema and murk_get to read an in-scope key; both fail \
closed on anything outside the grant."
.to_string(),
)
}
}
#[tokio::main]
pub async fn serve(state: McpState, allow_exec: bool) -> Result<(), Box<dyn std::error::Error>> {
init_tracing();
tracing::info!("murk mcp: serving over stdio");
let service = MurkMcp::new(Arc::new(state), allow_exec)
.serve(stdio())
.await
.inspect_err(|e| tracing::error!("murk mcp: failed to start: {e:?}"))?;
service.waiting().await?;
tracing::info!("murk mcp: client disconnected, shutting down");
Ok(())
}
fn init_tracing() {
let _ = tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.with_target(false)
.try_init();
}
const EXEC_TIMEOUT: Duration = Duration::from_secs(120);
const OUTPUT_CAP: usize = 1 << 20;
fn tool_err(msg: &str) -> CallToolResult {
CallToolResult::error(vec![ContentBlock::text(msg.to_string())])
}
async fn read_capped<R: tokio::io::AsyncRead + Unpin>(
r: R,
cap: usize,
) -> std::io::Result<(Vec<u8>, bool)> {
let mut buf = Vec::new();
r.take(cap as u64 + 1).read_to_end(&mut buf).await?;
let over = buf.len() > cap;
buf.truncate(cap);
Ok((buf, over))
}
async fn run_command(
command: &[String],
secrets: &[(String, String)],
) -> Result<CallToolResult, McpError> {
let mut cmd = TokioCommand::new(&command[0]);
cmd.args(&command[1..]);
cmd.env_clear();
#[cfg(windows)]
let preserve: &[&str] = &[
"PATH",
"PATHEXT",
"SystemRoot",
"SystemDrive",
"ComSpec",
"WINDIR",
"TEMP",
"TMP",
"APPDATA",
"LOCALAPPDATA",
"USERPROFILE",
"HOMEDRIVE",
"HOMEPATH",
];
#[cfg(not(windows))]
let preserve: &[&str] = &["PATH", "HOME", "TERM"];
for var in preserve {
if let Ok(val) = std::env::var(var) {
cmd.env(var, val);
}
}
cmd.env("MURK_AGENT", "1");
cmd.env("MURK_STRICT", "1");
for (k, v) in secrets {
cmd.env(k, v);
}
cmd.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true);
let mut child = match cmd.spawn() {
Ok(c) => c,
Err(e) => return Ok(tool_err(&format!("failed to run {}: {e}", command[0]))),
};
let so = child.stdout.take().expect("stdout piped");
let se = child.stderr.take().expect("stderr piped");
let out_fut = read_capped(so, OUTPUT_CAP);
let err_fut = read_capped(se, OUTPUT_CAP);
tokio::pin!(out_fut, err_fut);
let deadline = tokio::time::sleep(EXEC_TIMEOUT);
tokio::pin!(deadline);
let mut ob: Option<Vec<u8>> = None;
let mut eb: Option<Vec<u8>> = None;
let mut over_cap = false;
let mut timed_out = false;
while ob.is_none() || eb.is_none() {
tokio::select! {
r = &mut out_fut, if ob.is_none() => {
let (b, o) = match r {
Ok(v) => v,
Err(e) => return Ok(tool_err(&format!("reading stdout: {e}"))),
};
if o { over_cap = true; let _ = child.start_kill(); }
ob = Some(b);
}
r = &mut err_fut, if eb.is_none() => {
let (b, o) = match r {
Ok(v) => v,
Err(e) => return Ok(tool_err(&format!("reading stderr: {e}"))),
};
if o { over_cap = true; let _ = child.start_kill(); }
eb = Some(b);
}
_ = &mut deadline => { timed_out = true; let _ = child.start_kill(); break; }
}
}
let status = child.wait().await.ok();
if timed_out {
return Ok(tool_err(&format!(
"command timed out after {}s and was killed",
EXEC_TIMEOUT.as_secs()
)));
}
let stdout = String::from_utf8_lossy(&ob.unwrap_or_default()).into_owned();
let stderr = String::from_utf8_lossy(&eb.unwrap_or_default()).into_owned();
let payload = serde_json::json!({
"exit_code": status.and_then(|s| s.code()),
"stdout": stdout,
"stderr": stderr,
"truncated": over_cap,
});
let text = serde_json::to_string_pretty(&payload).unwrap_or_else(|_| "{}".to_string());
if status.is_some_and(|s| s.success()) && !over_cap {
Ok(CallToolResult::success(vec![ContentBlock::text(text)]))
} else {
Ok(CallToolResult::error(vec![ContentBlock::text(text)]))
}
}