use serde_json::{json, Value};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt, BufReader},
net::{TcpListener, TcpStream},
};
use uuid::Uuid;
use crate::{
bindings::host::murmur::tool::run::{Status, ToolInput},
runtime::CapsuleStoreState,
};
pub(super) const BRIDGE_SERVER_KEY: &str = "claude_bridge";
const BRIDGE_PATH: &str = "/bridge";
pub(super) struct BridgeHandle {
pub(super) listener: TcpListener,
pub(super) url: String,
pub(super) token: String,
pub(super) session_id: String,
pub(super) allowed_tool_names: Vec<String>,
mcp_tools: Vec<Value>,
}
fn inventory_to_mcp_tools(inventory: &[Value]) -> Vec<Value> {
inventory
.iter()
.filter_map(|t| {
let name = t.get("name").and_then(Value::as_str)?;
let schema = t
.get("parameters")
.cloned()
.unwrap_or_else(|| json!({"type": "object"}));
let mut tool = json!({ "name": name, "inputSchema": schema });
if let Some(desc) = t.get("description").and_then(Value::as_str) {
tool["description"] = Value::String(desc.to_string());
}
Some(tool)
})
.collect()
}
pub(super) async fn bind_bridge(
bind_addr: &str,
inventory: &[Value],
) -> Option<BridgeHandle> {
let mcp_tools = inventory_to_mcp_tools(inventory);
if mcp_tools.is_empty() {
return None;
}
let listener = TcpListener::bind(format!("{bind_addr}:0")).await.ok()?;
let port = listener.local_addr().ok()?.port();
let host = if bind_addr.is_empty() { "127.0.0.1" } else { bind_addr };
let allowed_tool_names = mcp_tools
.iter()
.filter_map(|t| t.get("name").and_then(Value::as_str))
.map(|n| format!("mcp__{BRIDGE_SERVER_KEY}__{n}"))
.collect();
Some(BridgeHandle {
listener,
url: format!("http://{host}:{port}{BRIDGE_PATH}"),
token: Uuid::new_v4().simple().to_string(),
session_id: Uuid::new_v4().simple().to_string(),
allowed_tool_names,
mcp_tools,
})
}
impl BridgeHandle {
pub(super) fn mcp_config_json(&self) -> String {
json!({
"mcpServers": {
BRIDGE_SERVER_KEY: {
"type": "http",
"url": self.url,
"headers": { "Authorization": format!("Bearer {}", self.token) }
}
}
})
.to_string()
}
pub(super) fn codex_config_args(&self) -> Vec<String> {
let key = BRIDGE_SERVER_KEY;
vec![
"-c".into(),
format!("mcp_servers.{key}.url=\"{}\"", self.url),
"-c".into(),
format!("mcp_servers.{key}.http_headers.Authorization=\"Bearer {}\"", self.token),
"-c".into(),
format!("mcp_servers.{key}.default_tools_approval_mode=\"approve\""),
]
}
pub(super) async fn serve(&self, store: &CapsuleStoreState) {
loop {
match self.listener.accept().await {
Ok((stream, _)) => {
self.handle_connection(stream, store).await;
}
Err(_) => break,
}
}
}
async fn handle_connection(&self, stream: TcpStream, store: &CapsuleStoreState) {
use tokio::io::AsyncBufReadExt;
let (reader_half, mut writer) = stream.into_split();
let mut reader = BufReader::new(reader_half);
let mut request_line = String::new();
if reader.read_line(&mut request_line).await.is_err() {
return;
}
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("").to_string();
let mut content_length = 0usize;
let mut authorized = false;
loop {
let mut line = String::new();
match reader.read_line(&mut line).await {
Ok(0) | Err(_) => break,
Ok(_) => {}
}
if line.trim().is_empty() {
break;
}
let lower = line.to_ascii_lowercase();
let lower = lower.trim_end();
if let Some(rest) = lower.strip_prefix("content-length:") {
content_length = rest.trim().parse().unwrap_or(0);
} else if let Some(rest) = lower.strip_prefix("authorization:") {
authorized = rest.trim() == format!("bearer {}", self.token);
}
}
if method == "GET" {
write_response(&mut writer, "405 Method Not Allowed", None, None).await;
return;
}
if !authorized {
write_response(&mut writer, "401 Unauthorized", None, None).await;
return;
}
let mut body = vec![0u8; content_length];
if content_length > 0 && reader.read_exact(&mut body).await.is_err() {
return;
}
let request: Value = match serde_json::from_slice(&body) {
Ok(v) => v,
Err(_) => {
write_response(&mut writer, "400 Bad Request", None, None).await;
return;
}
};
let rpc_method = request.get("method").and_then(Value::as_str).unwrap_or("");
let id = request.get("id").cloned();
if id.is_none() {
write_response(&mut writer, "202 Accepted", None, None).await;
return;
}
let id = id.unwrap();
match rpc_method {
"initialize" => {
let protocol = request
.get("params")
.and_then(|p| p.get("protocolVersion"))
.and_then(Value::as_str)
.unwrap_or("2024-11-05");
let result = json!({
"protocolVersion": protocol,
"capabilities": { "tools": {} },
"serverInfo": { "name": "murmur-claude-bridge", "version": env!("CARGO_PKG_VERSION") }
});
let session_header = format!("Mcp-Session-Id: {}", self.session_id);
write_json_rpc(&mut writer, &id, result, Some(&session_header)).await;
}
"tools/list" => {
write_json_rpc(&mut writer, &id, json!({ "tools": self.mcp_tools }), None).await;
}
"tools/call" => {
let result = self.dispatch_tool_call(request.get("params"), store).await;
write_json_rpc(&mut writer, &id, result, None).await;
}
_ => {
let err = json!({
"jsonrpc": "2.0", "id": id,
"error": { "code": -32601, "message": "method not found" }
});
write_response(
&mut writer,
"200 OK",
Some("application/json"),
Some(&err.to_string()),
)
.await;
}
}
}
async fn dispatch_tool_call(&self, params: Option<&Value>, store: &CapsuleStoreState) -> Value {
let name = params
.and_then(|p| p.get("name"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
let arguments = params
.and_then(|p| p.get("arguments"))
.cloned()
.unwrap_or_else(|| json!({}));
let input_json = serde_json::to_string(&arguments).unwrap_or_else(|_| "{}".to_string());
match store
.dispatch_agent_tool_async(
&name,
ToolInput { data: Some(input_json), log_path: None },
)
.await
{
Ok(outcome) => {
let is_error = !matches!(outcome.result.status, Status::Passed);
let text = outcome
.result
.data
.or(outcome.result.summary)
.unwrap_or_else(|| "tool returned no data".to_string());
json!({
"content": [{ "type": "text", "text": text }],
"isError": is_error
})
}
Err(err) => json!({
"content": [{ "type": "text", "text": format!("tool '{name}' failed: {err}") }],
"isError": true
}),
}
}
}
async fn write_json_rpc(
writer: &mut tokio::net::tcp::OwnedWriteHalf,
id: &Value,
result: Value,
extra_header: Option<&str>,
) {
let payload = json!({ "jsonrpc": "2.0", "id": id, "result": result }).to_string();
write_response_with_header(writer, "200 OK", Some("application/json"), Some(&payload), extra_header)
.await;
}
async fn write_response(
writer: &mut tokio::net::tcp::OwnedWriteHalf,
status: &str,
content_type: Option<&str>,
body: Option<&str>,
) {
write_response_with_header(writer, status, content_type, body, None).await;
}
async fn write_response_with_header(
writer: &mut tokio::net::tcp::OwnedWriteHalf,
status: &str,
content_type: Option<&str>,
body: Option<&str>,
extra_header: Option<&str>,
) {
let body = body.unwrap_or("");
let mut response = format!("HTTP/1.1 {status}\r\n");
if let Some(ct) = content_type {
response.push_str(&format!("content-type: {ct}\r\n"));
}
if let Some(h) = extra_header {
response.push_str(h);
response.push_str("\r\n");
}
response.push_str(&format!("content-length: {}\r\n", body.len()));
response.push_str("connection: close\r\n\r\n");
response.push_str(body);
let _ = writer.write_all(response.as_bytes()).await;
let _ = writer.shutdown().await;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn inventory_maps_to_mcp_tool_shape() {
let inventory = vec![json!({
"name": "murmur-tool-editor",
"description": "edits files",
"parameters": {"type": "object", "properties": {"path": {"type": "string"}}}
})];
let mcp = inventory_to_mcp_tools(&inventory);
assert_eq!(mcp.len(), 1);
assert_eq!(mcp[0]["name"], "murmur-tool-editor");
assert_eq!(mcp[0]["description"], "edits files");
assert_eq!(mcp[0]["inputSchema"]["type"], "object");
assert!(mcp[0].get("parameters").is_none());
}
#[test]
fn inventory_without_schema_defaults_to_object() {
let inventory = vec![json!({ "name": "t" })];
let mcp = inventory_to_mcp_tools(&inventory);
assert_eq!(mcp[0]["inputSchema"]["type"], "object");
}
#[tokio::test]
async fn bind_bridge_is_none_without_tools() {
assert!(bind_bridge("127.0.0.1", &[]).await.is_none());
}
#[tokio::test]
async fn bind_bridge_builds_config_and_allowlist() {
let inventory = vec![json!({"name": "editor", "parameters": {"type": "object"}})];
let handle = bind_bridge("127.0.0.1", &inventory)
.await
.expect("bridge should bind when tools are declared");
assert_eq!(handle.allowed_tool_names, vec!["mcp__claude_bridge__editor"]);
let cfg = handle.mcp_config_json();
assert!(cfg.contains("\"type\":\"http\""));
assert!(cfg.contains(&handle.url));
assert!(cfg.contains(&format!("Bearer {}", handle.token)));
}
}