use std::sync::Arc;
use rmcp::handler::server::wrapper::{Json, Parameters};
use rmcp::model::{
Implementation, InitializeResult, ListToolsResult, PaginatedRequestParams, ProtocolVersion,
ServerCapabilities,
};
use rmcp::service::RequestContext;
use rmcp::transport::stdio;
use rmcp::{tool, tool_handler, tool_router, ErrorData as RmcpError, RoleServer, ServerHandler, ServiceExt};
use serde_json::{Map, Value};
use crate::errors::{McpError, McpErrorKind};
use crate::session::Session;
use crate::tools;
pub const MCP_PROTOCOL_VERSION: &str = "2025-06-18";
type ToolArgs = Map<String, Value>;
type ToolResultBody = Map<String, Value>;
#[derive(Clone)]
pub struct TsafeMcpServer {
session: Arc<Session>,
}
impl TsafeMcpServer {
pub fn new(session: Session) -> Self {
Self {
session: Arc::new(session),
}
}
fn dispatch(&self, name: &'static str, params: ToolArgs) -> Result<Json<ToolResultBody>, RmcpError> {
let raw = Value::Object(params);
match tools::dispatch(&self.session, name, raw) {
Ok(payload) => {
let body = match payload {
Value::Object(obj) => obj,
other => {
let mut map = serde_json::Map::new();
map.insert("result".to_string(), other);
map
}
};
Ok(Json(body))
}
Err(e) => Err(mcp_error_to_rmcp(e)),
}
}
}
fn mcp_error_to_rmcp(e: McpError) -> RmcpError {
match e.kind {
McpErrorKind::ParseError
| McpErrorKind::InvalidRequest
| McpErrorKind::InvalidParams => RmcpError::invalid_params(e.message, None),
McpErrorKind::MethodNotFound => RmcpError::invalid_params(
format!("method not found: {}", e.message),
None,
),
_ => {
tracing::warn!(
error_code = e.code,
error = %e.message,
"mcp: tool error mapped to internal_error"
);
RmcpError::internal_error(format!("[{}] {}", e.code, e.message), None)
}
}
}
#[tool_router]
impl TsafeMcpServer {
#[tool(
name = "tsafe_run",
description = "Execute a command with explicitly-allowed vault keys injected as environment variables. Returns stdout/stderr/exit_code/duration_ms and the names of keys injected — never the secret values."
)]
async fn tsafe_run(
&self,
Parameters(p): Parameters<ToolArgs>,
) -> Result<Json<ToolResultBody>, RmcpError> {
self.dispatch("tsafe_run", p)
}
#[tool(
name = "tsafe_list_keys",
description = "List vault key names visible to this server, filtered by scope. Optionally narrow by namespace prefix. Values are never returned."
)]
async fn tsafe_list_keys(
&self,
Parameters(p): Parameters<ToolArgs>,
) -> Result<Json<ToolResultBody>, RmcpError> {
self.dispatch("tsafe_list_keys", p)
}
#[tool(
name = "tsafe_search_keys",
description = "Case-insensitive substring search across scope-filtered vault key names. Returns key names only."
)]
async fn tsafe_search_keys(
&self,
Parameters(p): Parameters<ToolArgs>,
) -> Result<Json<ToolResultBody>, RmcpError> {
self.dispatch("tsafe_search_keys", p)
}
#[tool(
name = "tsafe_has_key",
description = "Check whether a vault key exists within this server's scope. Out-of-scope keys always return present=false regardless of vault contents."
)]
async fn tsafe_has_key(
&self,
Parameters(p): Parameters<ToolArgs>,
) -> Result<Json<ToolResultBody>, RmcpError> {
self.dispatch("tsafe_has_key", p)
}
#[tool(
name = "tsafe_audit_tail",
description = "Return the most recent audit entries for the bound profile. Values are redacted; only id, timestamp, operation, key, status, and source are surfaced."
)]
async fn tsafe_audit_tail(
&self,
Parameters(p): Parameters<ToolArgs>,
) -> Result<Json<ToolResultBody>, RmcpError> {
self.dispatch("tsafe_audit_tail", p)
}
#[tool(
name = "tsafe_status",
description = "Return agent/vault/profile status plus this server's configured scope. Matches ADR-029 schema version 1."
)]
async fn tsafe_status(
&self,
Parameters(p): Parameters<ToolArgs>,
) -> Result<Json<ToolResultBody>, RmcpError> {
self.dispatch("tsafe_status", p)
}
#[tool(
name = "tsafe_reveal",
description = "Return the plaintext value of a single in-scope vault key. Gated by --allow-reveal; audited; biometric re-prompt when configured. The explicit escape hatch — every call appears in the profile audit log."
)]
async fn tsafe_reveal(
&self,
Parameters(p): Parameters<ToolArgs>,
) -> Result<Json<ToolResultBody>, RmcpError> {
self.dispatch("tsafe_reveal", p)
}
}
#[tool_handler]
impl ServerHandler for TsafeMcpServer {
fn get_info(&self) -> rmcp::model::ServerInfo {
let capabilities = ServerCapabilities::builder().enable_tools().build();
let server_info = Implementation::new(
"tsafe".to_string(),
env!("CARGO_PKG_VERSION").to_string(),
);
let instructions = "tsafe-mcp: action-shaped secrets runtime. No secret values reach \
the LLM context by default. Use tsafe_run to execute commands with \
injected env vars. tsafe_reveal is only available when the server \
was started with --allow-reveal; every reveal call is audited.";
InitializeResult::new(capabilities)
.with_protocol_version(ProtocolVersion::V_2025_06_18)
.with_server_info(server_info)
.with_instructions(instructions)
}
async fn list_tools(
&self,
_request: Option<PaginatedRequestParams>,
_context: RequestContext<RoleServer>,
) -> Result<ListToolsResult, RmcpError> {
let mut tools = Self::tool_router().list_all();
if !self.session.allow_reveal {
tools.retain(|t| t.name != "tsafe_reveal");
}
Ok(ListToolsResult {
tools,
meta: None,
next_cursor: None,
})
}
}
pub async fn serve_stdio(session: Session) -> anyhow::Result<()> {
tracing::info!("tsafe-mcp: rmcp 1.7 stdio server starting");
let server = TsafeMcpServer::new(session);
let service = server
.serve(stdio())
.await
.map_err(|e| anyhow::anyhow!("serve_stdio init failed: {e}"))?;
service
.waiting()
.await
.map_err(|e| anyhow::anyhow!("serve_stdio loop failed: {e}"))?;
tracing::info!("tsafe-mcp: rmcp stdio server shutdown (EOF)");
Ok(())
}
#[allow(dead_code)]
pub const TOOL_NAMES: &[&str] = &[
"tsafe_run",
"tsafe_list_keys",
"tsafe_search_keys",
"tsafe_has_key",
"tsafe_audit_tail",
"tsafe_status",
"tsafe_reveal",
];
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn tool_names_are_unique() {
let mut seen = std::collections::HashSet::new();
for name in TOOL_NAMES {
assert!(seen.insert(name), "duplicate tool name: {name}");
}
}
#[test]
fn tool_names_count() {
assert_eq!(TOOL_NAMES.len(), 7, "tsafe-mcp v1 ships 7 tools (6 default + reveal)");
}
}