From 2ae2020d405b7e1a9601e91bdbbe97b473b4d0d3 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Wed, 4 Feb 2026 14:36:56 -0800 Subject: [PATCH] feat: add MCP registry and OpenCode MCP endpoints --- .turbo | 1 + dist | 1 + node_modules | 1 + server/packages/sandbox-agent/src/lib.rs | 1 + server/packages/sandbox-agent/src/mcp.rs | 581 ++++++++++++++++++ .../sandbox-agent/src/opencode_compat.rs | 126 +++- server/packages/sandbox-agent/src/router.rs | 67 ++ .../tests/opencode-compat/mcp.test.ts | 194 ++++++ target | 1 + 9 files changed, 951 insertions(+), 22 deletions(-) create mode 120000 .turbo create mode 120000 dist create mode 120000 node_modules create mode 100644 server/packages/sandbox-agent/src/mcp.rs create mode 100644 server/packages/sandbox-agent/tests/opencode-compat/mcp.test.ts create mode 120000 target diff --git a/.turbo b/.turbo new file mode 120000 index 0000000..0b7d9ca --- /dev/null +++ b/.turbo @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/.turbo \ No newline at end of file diff --git a/dist b/dist new file mode 120000 index 0000000..f02d77f --- /dev/null +++ b/dist @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/dist \ No newline at end of file diff --git a/node_modules b/node_modules new file mode 120000 index 0000000..501480b --- /dev/null +++ b/node_modules @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/node_modules \ No newline at end of file diff --git a/server/packages/sandbox-agent/src/lib.rs b/server/packages/sandbox-agent/src/lib.rs index 8c11343..1c90009 100644 --- a/server/packages/sandbox-agent/src/lib.rs +++ b/server/packages/sandbox-agent/src/lib.rs @@ -2,6 +2,7 @@ mod agent_server_logs; pub mod credentials; +mod mcp; pub mod opencode_compat; pub mod router; pub mod server_logs; diff --git a/server/packages/sandbox-agent/src/mcp.rs b/server/packages/sandbox-agent/src/mcp.rs new file mode 100644 index 0000000..fd9cb32 --- /dev/null +++ b/server/packages/sandbox-agent/src/mcp.rs @@ -0,0 +1,581 @@ +use std::collections::HashMap; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter}; +use tokio::process::{Child, ChildStdin, ChildStdout, Command}; + +#[derive(Debug, Clone)] +pub(crate) struct McpOAuthConfig { + pub client_id: Option, + pub client_secret: Option, + pub scope: Option, +} + +#[derive(Debug, Clone)] +pub(crate) enum McpConfig { + Local { + command: Vec, + environment: HashMap, + enabled: bool, + timeout_ms: Option, + }, + Remote { + url: String, + headers: HashMap, + oauth: Option, + enabled: bool, + timeout_ms: Option, + }, +} + +impl McpConfig { + pub(crate) fn from_value(value: &Value) -> Result { + let config_type = value + .get("type") + .and_then(|v| v.as_str()) + .ok_or_else(|| "config.type is required".to_string())?; + match config_type { + "local" => { + let command = value + .get("command") + .and_then(|v| v.as_array()) + .ok_or_else(|| "config.command is required".to_string())? + .iter() + .map(|item| { + item.as_str() + .map(|s| s.to_string()) + .ok_or_else(|| "config.command must be an array of strings".to_string()) + }) + .collect::, String>>()?; + if command.is_empty() { + return Err("config.command cannot be empty".to_string()); + } + let environment = parse_string_map(value.get("environment"))?; + let enabled = value + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let timeout_ms = value + .get("timeout") + .and_then(|v| v.as_u64()); + Ok(McpConfig::Local { + command, + environment, + enabled, + timeout_ms, + }) + } + "remote" => { + let url = value + .get("url") + .and_then(|v| v.as_str()) + .ok_or_else(|| "config.url is required".to_string())? + .to_string(); + let headers = parse_string_map(value.get("headers"))?; + let enabled = value + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let timeout_ms = value + .get("timeout") + .and_then(|v| v.as_u64()); + let oauth = parse_oauth(value.get("oauth"))?; + Ok(McpConfig::Remote { + url, + headers, + oauth, + enabled, + timeout_ms, + }) + } + other => Err(format!("unsupported config.type: {other}")), + } + } + + fn requires_auth(&self) -> bool { + match self { + McpConfig::Local { .. } => false, + McpConfig::Remote { oauth, .. } => oauth.is_some(), + } + } + + fn enabled(&self) -> bool { + match self { + McpConfig::Local { enabled, .. } => *enabled, + McpConfig::Remote { enabled, .. } => *enabled, + } + } +} + +#[derive(Debug, Clone)] +pub(crate) enum McpStatus { + Connected, + Disabled, + Failed { error: String }, + NeedsAuth, + NeedsClientRegistration { error: String }, +} + +impl McpStatus { + pub(crate) fn as_json(&self) -> Value { + match self { + McpStatus::Connected => json!({"status": "connected"}), + McpStatus::Disabled => json!({"status": "disabled"}), + McpStatus::Failed { error } => json!({"status": "failed", "error": error}), + McpStatus::NeedsAuth => json!({"status": "needs_auth"}), + McpStatus::NeedsClientRegistration { error } => { + json!({"status": "needs_client_registration", "error": error}) + } + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct McpTool { + pub name: String, + pub description: String, + pub input_schema: Value, +} + +#[derive(Debug)] +struct McpStdioConnection { + child: Child, + stdin: BufWriter, + stdout: BufReader, + next_id: u64, +} + +impl McpStdioConnection { + async fn spawn(command: &[String], environment: &HashMap) -> Result { + let mut cmd = Command::new(&command[0]); + if command.len() > 1 { + cmd.args(&command[1..]); + } + cmd.stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::null()); + for (key, value) in environment { + cmd.env(key, value); + } + let mut child = cmd + .spawn() + .map_err(|err| McpError::Failed(format!("failed to spawn MCP server: {err}")))?; + let stdin = child + .stdin + .take() + .ok_or_else(|| McpError::Failed("failed to capture MCP stdin".to_string()))?; + let stdout = child + .stdout + .take() + .ok_or_else(|| McpError::Failed("failed to capture MCP stdout".to_string()))?; + Ok(Self { + child, + stdin: BufWriter::new(stdin), + stdout: BufReader::new(stdout), + next_id: 0, + }) + } + + async fn request(&mut self, method: &str, params: Value) -> Result { + self.next_id += 1; + let id = self.next_id; + let payload = json!({ + "jsonrpc": "2.0", + "id": id, + "method": method, + "params": params, + }); + let mut line = serde_json::to_string(&payload) + .map_err(|err| McpError::Failed(format!("failed to encode MCP request: {err}")))?; + line.push('\n'); + self.stdin + .write_all(line.as_bytes()) + .await + .map_err(|err| McpError::Failed(format!("failed to write MCP request: {err}")))?; + self.stdin + .flush() + .await + .map_err(|err| McpError::Failed(format!("failed to flush MCP request: {err}")))?; + + loop { + let mut buffer = String::new(); + let read = self + .stdout + .read_line(&mut buffer) + .await + .map_err(|err| McpError::Failed(format!("failed to read MCP response: {err}")))?; + if read == 0 { + return Err(McpError::Failed( + "MCP server closed stdout before responding".to_string(), + )); + } + let value: Value = serde_json::from_str(buffer.trim()) + .map_err(|err| McpError::Failed(format!("invalid MCP response: {err}")))?; + let response_id = value.get("id").and_then(|v| v.as_u64()); + if response_id != Some(id) { + continue; + } + if let Some(error) = value.get("error") { + return Err(McpError::Failed(format!( + "MCP request failed: {error}" + ))); + } + if let Some(result) = value.get("result") { + return Ok(result.clone()); + } + return Err(McpError::Failed("MCP response missing result".to_string())); + } + } +} + +#[derive(Debug)] +enum McpConnection { + Stdio(McpStdioConnection), +} + +#[derive(Debug)] +struct McpServerState { + name: String, + config: McpConfig, + status: McpStatus, + tools: Vec, + auth_token: Option, + connection: Option, +} + +#[derive(Debug)] +pub(crate) struct McpRegistry { + servers: HashMap, +} + +impl McpRegistry { + pub(crate) fn new() -> Self { + Self { + servers: HashMap::new(), + } + } + + pub(crate) fn status_map(&self) -> Value { + let mut map = serde_json::Map::new(); + for (name, server) in &self.servers { + map.insert(name.clone(), server.status.as_json()); + } + Value::Object(map) + } + + pub(crate) async fn register(&mut self, name: String, config: McpConfig) -> Result<(), McpError> { + if let Some(mut existing) = self.servers.remove(&name) { + existing.disconnect().await; + } + let status = if !config.enabled() { + McpStatus::Disabled + } else if config.requires_auth() { + McpStatus::NeedsAuth + } else { + McpStatus::Disabled + }; + self.servers.insert( + name.clone(), + McpServerState { + name, + config, + status, + tools: Vec::new(), + auth_token: None, + connection: None, + }, + ); + Ok(()) + } + + pub(crate) fn start_auth(&mut self, name: &str) -> Result { + let server = self + .servers + .get_mut(name) + .ok_or(McpError::NotFound)?; + if !server.config.requires_auth() { + return Err(McpError::Invalid("MCP server does not require auth".to_string())); + } + server.status = McpStatus::NeedsAuth; + Ok(match &server.config { + McpConfig::Remote { url, .. } => format!("{}/oauth/authorize", url.trim_end_matches('/')), + McpConfig::Local { .. } => "http://localhost/oauth/authorize".to_string(), + }) + } + + pub(crate) fn auth_callback(&mut self, name: &str, code: String) -> Result { + let server = self + .servers + .get_mut(name) + .ok_or(McpError::NotFound)?; + if !server.config.requires_auth() { + return Err(McpError::Invalid("MCP server does not require auth".to_string())); + } + if code.is_empty() { + return Err(McpError::Invalid("code is required".to_string())); + } + server.auth_token = Some(code); + server.status = McpStatus::Disabled; + Ok(server.status.clone()) + } + + pub(crate) fn auth_authenticate(&mut self, name: &str) -> Result { + let server = self + .servers + .get_mut(name) + .ok_or(McpError::NotFound)?; + if !server.config.requires_auth() { + return Err(McpError::Invalid("MCP server does not require auth".to_string())); + } + server.auth_token = Some("authenticated".to_string()); + server.status = McpStatus::Disabled; + Ok(server.status.clone()) + } + + pub(crate) fn auth_remove(&mut self, name: &str) -> Result { + let server = self + .servers + .get_mut(name) + .ok_or(McpError::NotFound)?; + server.auth_token = None; + server.status = if server.config.requires_auth() { + McpStatus::NeedsAuth + } else { + McpStatus::Disabled + }; + Ok(server.status.clone()) + } + + pub(crate) async fn connect(&mut self, name: &str) -> Result { + let server = self + .servers + .get_mut(name) + .ok_or(McpError::NotFound)?; + if server.config.requires_auth() && server.auth_token.is_none() { + server.status = McpStatus::NeedsAuth; + return Err(McpError::AuthRequired); + } + let tools = match &server.config { + McpConfig::Local { + command, + environment, + .. + } => { + let mut connection = McpStdioConnection::spawn(command, environment).await?; + let _ = connection + .request( + "initialize", + json!({ + "clientInfo": {"name": "sandbox-agent", "version": "0.1.0"}, + "protocolVersion": "2024-11-05", + }), + ) + .await?; + let result = connection + .request("tools/list", json!({})) + .await?; + let tools = parse_tools(&result)?; + server.connection = Some(McpConnection::Stdio(connection)); + tools + } + McpConfig::Remote { + url, + headers, + .. + } => { + let client = reqwest::Client::new(); + let auth_token = server.auth_token.clone(); + let _ = remote_request(&client, url, headers, auth_token.as_deref(), "initialize", json!({ + "clientInfo": {"name": "sandbox-agent", "version": "0.1.0"}, + "protocolVersion": "2024-11-05", + })) + .await?; + let result = remote_request( + &client, + url, + headers, + auth_token.as_deref(), + "tools/list", + json!({}), + ) + .await?; + parse_tools(&result)? + } + }; + server.tools = tools; + server.status = McpStatus::Connected; + Ok(true) + } + + pub(crate) async fn disconnect(&mut self, name: &str) -> Result { + let server = self + .servers + .get_mut(name) + .ok_or(McpError::NotFound)?; + server.disconnect().await; + server.status = McpStatus::Disabled; + Ok(true) + } + + pub(crate) fn tool_ids(&self) -> Vec { + let mut ids = Vec::new(); + for server in self.servers.values() { + if matches!(server.status, McpStatus::Connected) { + for tool in &server.tools { + ids.push(format!("mcp:{}:{}", server.name, tool.name)); + } + } + } + ids + } + + pub(crate) fn tool_list(&self) -> Vec { + let mut list = Vec::new(); + for server in self.servers.values() { + if matches!(server.status, McpStatus::Connected) { + for tool in &server.tools { + list.push(json!({ + "id": format!("mcp:{}:{}", server.name, tool.name), + "description": tool.description, + "parameters": tool.input_schema, + })); + } + } + } + list + } +} + +impl McpServerState { + async fn disconnect(&mut self) { + if let Some(connection) = self.connection.as_mut() { + match connection { + McpConnection::Stdio(conn) => { + let _ = conn.child.kill().await; + } + } + } + self.connection = None; + } +} + +fn parse_string_map(value: Option<&Value>) -> Result, String> { + let mut map = HashMap::new(); + let Some(value) = value else { + return Ok(map); + }; + let obj = value + .as_object() + .ok_or_else(|| "expected object".to_string())?; + for (key, value) in obj { + let str_value = value + .as_str() + .ok_or_else(|| "expected string value".to_string())?; + map.insert(key.clone(), str_value.to_string()); + } + Ok(map) +} + +fn parse_oauth(value: Option<&Value>) -> Result, String> { + let Some(value) = value else { + return Ok(None); + }; + if let Some(flag) = value.as_bool() { + if flag { + return Err("oauth must be an object or false".to_string()); + } + return Ok(None); + } + let obj = value + .as_object() + .ok_or_else(|| "oauth must be an object or false".to_string())?; + let client_id = obj.get("clientId").and_then(|v| v.as_str()).map(|v| v.to_string()); + let client_secret = obj + .get("clientSecret") + .and_then(|v| v.as_str()) + .map(|v| v.to_string()); + let scope = obj.get("scope").and_then(|v| v.as_str()).map(|v| v.to_string()); + Ok(Some(McpOAuthConfig { + client_id, + client_secret, + scope, + })) +} + +fn parse_tools(value: &Value) -> Result, McpError> { + let tools_value = value + .get("tools") + .and_then(|v| v.as_array()) + .ok_or_else(|| McpError::Failed("MCP tools/list response missing tools".to_string()))?; + let mut tools = Vec::new(); + for tool in tools_value { + let name = tool + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| McpError::Failed("tool name missing".to_string()))? + .to_string(); + let description = tool + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + let input_schema = tool + .get("inputSchema") + .cloned() + .unwrap_or_else(|| json!({})); + tools.push(McpTool { + name, + description, + input_schema, + }); + } + Ok(tools) +} + +async fn remote_request( + client: &reqwest::Client, + url: &str, + headers: &HashMap, + auth_token: Option<&str>, + method: &str, + params: Value, +) -> Result { + let payload = json!({ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }); + let mut request = client.post(url).json(&payload); + for (key, value) in headers { + request = request.header(key, value); + } + if let Some(token) = auth_token { + request = request.header("Authorization", format!("Bearer {token}")); + } + let response = request + .send() + .await + .map_err(|err| McpError::Failed(format!("MCP request failed: {err}")))?; + let text = response + .text() + .await + .map_err(|err| McpError::Failed(format!("MCP response read failed: {err}")))?; + let value: Value = serde_json::from_str(&text) + .map_err(|err| McpError::Failed(format!("MCP response invalid: {err}")))?; + if let Some(error) = value.get("error") { + return Err(McpError::Failed(format!("MCP request failed: {error}"))); + } + value + .get("result") + .cloned() + .ok_or_else(|| McpError::Failed("MCP response missing result".to_string())) +} + +#[derive(Debug)] +pub(crate) enum McpError { + NotFound, + Invalid(String), + AuthRequired, + Failed(String), +} diff --git a/server/packages/sandbox-agent/src/opencode_compat.rs b/server/packages/sandbox-agent/src/opencode_compat.rs index 55b7050..576a2c1 100644 --- a/server/packages/sandbox-agent/src/opencode_compat.rs +++ b/server/packages/sandbox-agent/src/opencode_compat.rs @@ -23,6 +23,7 @@ use tokio::sync::{broadcast, Mutex}; use tokio::time::interval; use utoipa::{IntoParams, OpenApi, ToSchema}; +use crate::mcp::{McpConfig, McpError}; use crate::router::{AppState, CreateSessionRequest, PermissionReply}; use sandbox_agent_error::SandboxError; use sandbox_agent_agent_management::agents::AgentId; @@ -555,6 +556,18 @@ struct PermissionGlobalReplyRequest { reply: Option, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +struct McpRegisterRequest { + name: String, + config: Value, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +struct McpAuthCallbackRequest { + code: Option, +} + #[derive(Debug, Serialize, Deserialize, ToSchema)] #[serde(rename_all = "camelCase")] struct QuestionReplyBody { @@ -769,6 +782,15 @@ fn sandbox_error_response(err: SandboxError) -> (StatusCode, Json) { } } +fn mcp_error_response(err: McpError) -> (StatusCode, Json) { + match err { + McpError::NotFound => not_found("MCP server not found"), + McpError::Invalid(message) => bad_request(&message), + McpError::AuthRequired => bad_request("MCP auth required"), + McpError::Failed(message) => internal_error(&message), + } +} + fn parse_permission_reply_value(value: Option<&str>) -> Result { let value = value.unwrap_or("once").to_ascii_lowercase(); match value.as_str() { @@ -3833,18 +3855,35 @@ async fn oc_find_symbols(Query(query): Query) -> impl IntoResp responses((status = 200)), tag = "opencode" )] -async fn oc_mcp_list() -> impl IntoResponse { - (StatusCode::OK, Json(json!({}))) +async fn oc_mcp_list(State(state): State>) -> impl IntoResponse { + let status = state.inner.session_manager().mcp_status_map().await; + (StatusCode::OK, Json(status)) } #[utoipa::path( post, path = "/mcp", + request_body = McpRegisterRequest, responses((status = 200)), tag = "opencode" )] -async fn oc_mcp_register() -> impl IntoResponse { - (StatusCode::OK, Json(json!({}))) +async fn oc_mcp_register( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + let config = match McpConfig::from_value(&body.config) { + Ok(config) => config, + Err(message) => return bad_request(&message).into_response(), + }; + match state + .inner + .session_manager() + .mcp_register(body.name, config) + .await + { + Ok(status) => (StatusCode::OK, Json(status)).into_response(), + Err(err) => mcp_error_response(err).into_response(), + } } #[utoipa::path( @@ -3855,10 +3894,13 @@ async fn oc_mcp_register() -> impl IntoResponse { tag = "opencode" )] async fn oc_mcp_auth( - Path(_name): Path, - _body: Option>, + State(state): State>, + Path(name): Path, ) -> impl IntoResponse { - (StatusCode::OK, Json(json!({"status": "needs_auth"}))) + match state.inner.session_manager().mcp_auth_start(&name).await { + Ok(url) => (StatusCode::OK, Json(json!({"authorizationUrl": url}))).into_response(), + Err(err) => mcp_error_response(err).into_response(), + } } #[utoipa::path( @@ -3868,22 +3910,41 @@ async fn oc_mcp_auth( responses((status = 200)), tag = "opencode" )] -async fn oc_mcp_auth_remove(Path(_name): Path) -> impl IntoResponse { - (StatusCode::OK, Json(json!({"status": "disabled"}))) +async fn oc_mcp_auth_remove( + State(state): State>, + Path(name): Path, +) -> impl IntoResponse { + match state.inner.session_manager().mcp_auth_remove(&name).await { + Ok(status) => (StatusCode::OK, Json(status.as_json())).into_response(), + Err(err) => mcp_error_response(err).into_response(), + } } #[utoipa::path( post, path = "/mcp/{name}/auth/callback", params(("name" = String, Path, description = "MCP server name")), + request_body = McpAuthCallbackRequest, responses((status = 200)), tag = "opencode" )] async fn oc_mcp_auth_callback( - Path(_name): Path, - _body: Option>, + State(state): State>, + Path(name): Path, + Json(body): Json, ) -> impl IntoResponse { - (StatusCode::OK, Json(json!({"status": "needs_auth"}))) + let Some(code) = body.code else { + return bad_request("code is required").into_response(); + }; + match state + .inner + .session_manager() + .mcp_auth_callback(&name, code) + .await + { + Ok(status) => (StatusCode::OK, Json(status.as_json())).into_response(), + Err(err) => mcp_error_response(err).into_response(), + } } #[utoipa::path( @@ -3895,10 +3956,14 @@ async fn oc_mcp_auth_callback( tag = "opencode" )] async fn oc_mcp_authenticate( - Path(_name): Path, + State(state): State>, + Path(name): Path, _body: Option>, ) -> impl IntoResponse { - (StatusCode::OK, Json(json!({"status": "needs_auth"}))) + match state.inner.session_manager().mcp_auth_authenticate(&name).await { + Ok(status) => (StatusCode::OK, Json(status.as_json())).into_response(), + Err(err) => mcp_error_response(err).into_response(), + } } #[utoipa::path( @@ -3908,8 +3973,14 @@ async fn oc_mcp_authenticate( responses((status = 200)), tag = "opencode" )] -async fn oc_mcp_connect(Path(_name): Path) -> impl IntoResponse { - bool_ok(true) +async fn oc_mcp_connect( + State(state): State>, + Path(name): Path, +) -> impl IntoResponse { + match state.inner.session_manager().mcp_connect(&name).await { + Ok(result) => bool_ok(result).into_response(), + Err(err) => mcp_error_response(err).into_response(), + } } #[utoipa::path( @@ -3919,8 +3990,14 @@ async fn oc_mcp_connect(Path(_name): Path) -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_mcp_disconnect(Path(_name): Path) -> impl IntoResponse { - bool_ok(true) +async fn oc_mcp_disconnect( + State(state): State>, + Path(name): Path, +) -> impl IntoResponse { + match state.inner.session_manager().mcp_disconnect(&name).await { + Ok(result) => bool_ok(result).into_response(), + Err(err) => mcp_error_response(err).into_response(), + } } #[utoipa::path( @@ -3929,8 +4006,9 @@ async fn oc_mcp_disconnect(Path(_name): Path) -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tool_ids() -> impl IntoResponse { - (StatusCode::OK, Json(json!([]))) +async fn oc_tool_ids(State(state): State>) -> impl IntoResponse { + let ids = state.inner.session_manager().mcp_tool_ids().await; + (StatusCode::OK, Json(ids)) } #[utoipa::path( @@ -3939,11 +4017,15 @@ async fn oc_tool_ids() -> impl IntoResponse { responses((status = 200)), tag = "opencode" )] -async fn oc_tool_list(Query(query): Query) -> impl IntoResponse { +async fn oc_tool_list( + State(state): State>, + Query(query): Query, +) -> impl IntoResponse { if query.provider.is_none() || query.model.is_none() { return bad_request("provider and model are required").into_response(); } - (StatusCode::OK, Json(json!([]))).into_response() + let tools = state.inner.session_manager().mcp_tool_list().await; + (StatusCode::OK, Json(tools)).into_response() } #[utoipa::path( diff --git a/server/packages/sandbox-agent/src/router.rs b/server/packages/sandbox-agent/src/router.rs index 3ca437a..7ab99e0 100644 --- a/server/packages/sandbox-agent/src/router.rs +++ b/server/packages/sandbox-agent/src/router.rs @@ -818,6 +818,7 @@ pub(crate) struct SessionManager { sessions: Mutex>, server_manager: Arc, http_client: Client, + mcp_registry: Mutex, } /// Shared Codex app-server process that handles multiple sessions via JSON-RPC. @@ -1538,6 +1539,7 @@ impl SessionManager { sessions: Mutex::new(Vec::new()), server_manager, http_client: Client::new(), + mcp_registry: Mutex::new(crate::mcp::McpRegistry::new()), } } @@ -1689,6 +1691,71 @@ impl SessionManager { } } + pub(crate) async fn mcp_status_map(&self) -> Value { + let registry = self.mcp_registry.lock().await; + registry.status_map() + } + + pub(crate) async fn mcp_register( + &self, + name: String, + config: crate::mcp::McpConfig, + ) -> Result { + let mut registry = self.mcp_registry.lock().await; + registry.register(name, config).await?; + Ok(registry.status_map()) + } + + pub(crate) async fn mcp_auth_start(&self, name: &str) -> Result { + let mut registry = self.mcp_registry.lock().await; + registry.start_auth(name) + } + + pub(crate) async fn mcp_auth_callback( + &self, + name: &str, + code: String, + ) -> Result { + let mut registry = self.mcp_registry.lock().await; + registry.auth_callback(name, code) + } + + pub(crate) async fn mcp_auth_authenticate( + &self, + name: &str, + ) -> Result { + let mut registry = self.mcp_registry.lock().await; + registry.auth_authenticate(name) + } + + pub(crate) async fn mcp_auth_remove( + &self, + name: &str, + ) -> Result { + let mut registry = self.mcp_registry.lock().await; + registry.auth_remove(name) + } + + pub(crate) async fn mcp_connect(&self, name: &str) -> Result { + let mut registry = self.mcp_registry.lock().await; + registry.connect(name).await + } + + pub(crate) async fn mcp_disconnect(&self, name: &str) -> Result { + let mut registry = self.mcp_registry.lock().await; + registry.disconnect(name).await + } + + pub(crate) async fn mcp_tool_ids(&self) -> Vec { + let registry = self.mcp_registry.lock().await; + registry.tool_ids() + } + + pub(crate) async fn mcp_tool_list(&self) -> Vec { + let registry = self.mcp_registry.lock().await; + registry.tool_list() + } + pub(crate) async fn send_message( self: &Arc, session_id: String, diff --git a/server/packages/sandbox-agent/tests/opencode-compat/mcp.test.ts b/server/packages/sandbox-agent/tests/opencode-compat/mcp.test.ts new file mode 100644 index 0000000..df2756b --- /dev/null +++ b/server/packages/sandbox-agent/tests/opencode-compat/mcp.test.ts @@ -0,0 +1,194 @@ +/** + * Tests for OpenCode MCP integration. + */ + +import { describe, it, expect, beforeAll, beforeEach, afterEach } from "vitest"; +import { createServer, type IncomingMessage, type ServerResponse } from "node:http"; +import type { AddressInfo } from "node:net"; +import { spawnSandboxAgent, buildSandboxAgent, type SandboxAgentHandle } from "./helpers/spawn"; + +interface McpServerHandle { + url: string; + close: () => Promise; +} + +async function startMcpServer(): Promise { + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + if (req.method !== "POST" || req.url !== "/mcp") { + res.statusCode = 404; + res.end(); + return; + } + + const body = await new Promise((resolve) => { + let data = ""; + req.on("data", (chunk) => { + data += chunk.toString(); + }); + req.on("end", () => resolve(data)); + }); + + let payload: any; + try { + payload = JSON.parse(body); + } catch { + res.statusCode = 400; + res.end(); + return; + } + + const authHeader = req.headers.authorization; + if (authHeader !== "Bearer test-token") { + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + id: payload?.id ?? null, + error: { code: 401, message: "unauthorized" }, + }) + ); + return; + } + + let result: any; + switch (payload?.method) { + case "initialize": + result = { + serverInfo: { name: "test-mcp", version: "0.1.0" }, + capabilities: { tools: {} }, + }; + break; + case "tools/list": + result = { + tools: [ + { + name: "echo", + description: "Echo text", + inputSchema: { + type: "object", + properties: { text: { type: "string" } }, + required: ["text"], + }, + }, + ], + }; + break; + default: + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + id: payload?.id ?? null, + error: { code: -32601, message: "method not found" }, + }) + ); + return; + } + + res.setHeader("Content-Type", "application/json"); + res.end( + JSON.stringify({ + jsonrpc: "2.0", + id: payload.id, + result, + }) + ); + }); + + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address() as AddressInfo; + const url = `http://127.0.0.1:${address.port}/mcp`; + return { + url, + close: () => new Promise((resolve) => server.close(() => resolve())), + }; +} + +describe("OpenCode MCP Integration", () => { + let handle: SandboxAgentHandle; + let mcpServer: McpServerHandle; + + beforeAll(async () => { + await buildSandboxAgent(); + }); + + beforeEach(async () => { + mcpServer = await startMcpServer(); + handle = await spawnSandboxAgent({ opencodeCompat: true }); + }); + + afterEach(async () => { + await handle?.dispose(); + await mcpServer?.close(); + }); + + it("should authenticate and list MCP tools", async () => { + const headers = { + Authorization: `Bearer ${handle.token}`, + "Content-Type": "application/json", + }; + + const registerResponse = await fetch(`${handle.baseUrl}/opencode/mcp`, { + method: "POST", + headers, + body: JSON.stringify({ + name: "test", + config: { + type: "remote", + url: mcpServer.url, + oauth: { clientId: "client" }, + enabled: true, + }, + }), + }); + expect(registerResponse.ok).toBe(true); + const registerData = await registerResponse.json(); + expect(registerData?.test?.status).toBe("needs_auth"); + + const authResponse = await fetch(`${handle.baseUrl}/opencode/mcp/test/auth`, { + method: "POST", + headers, + }); + expect(authResponse.ok).toBe(true); + const authData = await authResponse.json(); + expect(typeof authData?.authorizationUrl).toBe("string"); + + const callbackResponse = await fetch(`${handle.baseUrl}/opencode/mcp/test/auth/callback`, { + method: "POST", + headers, + body: JSON.stringify({ code: "test-token" }), + }); + expect(callbackResponse.ok).toBe(true); + const callbackData = await callbackResponse.json(); + expect(callbackData?.status).toBe("disabled"); + + const connectResponse = await fetch(`${handle.baseUrl}/opencode/mcp/test/connect`, { + method: "POST", + headers, + }); + expect(connectResponse.ok).toBe(true); + expect(await connectResponse.json()).toBe(true); + + const idsResponse = await fetch(`${handle.baseUrl}/opencode/experimental/tool/ids`, { + headers, + }); + expect(idsResponse.ok).toBe(true); + const ids = await idsResponse.json(); + expect(ids).toContain("mcp:test:echo"); + + const listResponse = await fetch( + `${handle.baseUrl}/opencode/experimental/tool?provider=sandbox-agent&model=mock`, + { headers } + ); + expect(listResponse.ok).toBe(true); + const tools = await listResponse.json(); + expect(tools).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: "mcp:test:echo", + description: "Echo text", + }), + ]) + ); + }); +}); diff --git a/target b/target new file mode 120000 index 0000000..3d6ad8c --- /dev/null +++ b/target @@ -0,0 +1 @@ +/home/nathan/sandbox-agent/target \ No newline at end of file