feat: add MCP registry and OpenCode MCP endpoints

This commit is contained in:
Nathan Flurry 2026-02-04 14:36:56 -08:00
parent 7378abee46
commit 2ae2020d40
9 changed files with 951 additions and 22 deletions

1
.turbo Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/.turbo

1
dist Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/dist

1
node_modules Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/node_modules

View file

@ -2,6 +2,7 @@
mod agent_server_logs;
pub mod credentials;
mod mcp;
pub mod opencode_compat;
pub mod router;
pub mod server_logs;

View file

@ -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<String>,
pub client_secret: Option<String>,
pub scope: Option<String>,
}
#[derive(Debug, Clone)]
pub(crate) enum McpConfig {
Local {
command: Vec<String>,
environment: HashMap<String, String>,
enabled: bool,
timeout_ms: Option<u64>,
},
Remote {
url: String,
headers: HashMap<String, String>,
oauth: Option<McpOAuthConfig>,
enabled: bool,
timeout_ms: Option<u64>,
},
}
impl McpConfig {
pub(crate) fn from_value(value: &Value) -> Result<Self, String> {
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::<Result<Vec<String>, 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<ChildStdin>,
stdout: BufReader<ChildStdout>,
next_id: u64,
}
impl McpStdioConnection {
async fn spawn(command: &[String], environment: &HashMap<String, String>) -> Result<Self, McpError> {
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<Value, McpError> {
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<McpTool>,
auth_token: Option<String>,
connection: Option<McpConnection>,
}
#[derive(Debug)]
pub(crate) struct McpRegistry {
servers: HashMap<String, McpServerState>,
}
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<String, McpError> {
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<McpStatus, McpError> {
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<McpStatus, McpError> {
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<McpStatus, McpError> {
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<bool, McpError> {
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<bool, McpError> {
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<String> {
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<Value> {
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<HashMap<String, String>, 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<Option<McpOAuthConfig>, 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<Vec<McpTool>, 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<String, String>,
auth_token: Option<&str>,
method: &str,
params: Value,
) -> Result<Value, McpError> {
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),
}

View file

@ -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<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
struct McpRegisterRequest {
name: String,
config: Value,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
struct McpAuthCallbackRequest {
code: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, ToSchema)]
#[serde(rename_all = "camelCase")]
struct QuestionReplyBody {
@ -769,6 +782,15 @@ fn sandbox_error_response(err: SandboxError) -> (StatusCode, Json<Value>) {
}
}
fn mcp_error_response(err: McpError) -> (StatusCode, Json<Value>) {
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<PermissionReply, String> {
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<FindSymbolsQuery>) -> 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<Arc<OpenCodeAppState>>) -> 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<Arc<OpenCodeAppState>>,
Json(body): Json<McpRegisterRequest>,
) -> 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<String>,
_body: Option<Json<Value>>,
State(state): State<Arc<OpenCodeAppState>>,
Path(name): Path<String>,
) -> 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<String>) -> impl IntoResponse {
(StatusCode::OK, Json(json!({"status": "disabled"})))
async fn oc_mcp_auth_remove(
State(state): State<Arc<OpenCodeAppState>>,
Path(name): Path<String>,
) -> 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<String>,
_body: Option<Json<Value>>,
State(state): State<Arc<OpenCodeAppState>>,
Path(name): Path<String>,
Json(body): Json<McpAuthCallbackRequest>,
) -> 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<String>,
State(state): State<Arc<OpenCodeAppState>>,
Path(name): Path<String>,
_body: Option<Json<Value>>,
) -> 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<String>) -> impl IntoResponse {
bool_ok(true)
async fn oc_mcp_connect(
State(state): State<Arc<OpenCodeAppState>>,
Path(name): Path<String>,
) -> 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<String>) -> impl IntoResponse {
responses((status = 200)),
tag = "opencode"
)]
async fn oc_mcp_disconnect(Path(_name): Path<String>) -> impl IntoResponse {
bool_ok(true)
async fn oc_mcp_disconnect(
State(state): State<Arc<OpenCodeAppState>>,
Path(name): Path<String>,
) -> 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<String>) -> 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<Arc<OpenCodeAppState>>) -> 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<ToolQuery>) -> impl IntoResponse {
async fn oc_tool_list(
State(state): State<Arc<OpenCodeAppState>>,
Query(query): Query<ToolQuery>,
) -> 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(

View file

@ -818,6 +818,7 @@ pub(crate) struct SessionManager {
sessions: Mutex<Vec<SessionState>>,
server_manager: Arc<AgentServerManager>,
http_client: Client,
mcp_registry: Mutex<crate::mcp::McpRegistry>,
}
/// 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<Value, crate::mcp::McpError> {
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<String, crate::mcp::McpError> {
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<crate::mcp::McpStatus, crate::mcp::McpError> {
let mut registry = self.mcp_registry.lock().await;
registry.auth_callback(name, code)
}
pub(crate) async fn mcp_auth_authenticate(
&self,
name: &str,
) -> Result<crate::mcp::McpStatus, crate::mcp::McpError> {
let mut registry = self.mcp_registry.lock().await;
registry.auth_authenticate(name)
}
pub(crate) async fn mcp_auth_remove(
&self,
name: &str,
) -> Result<crate::mcp::McpStatus, crate::mcp::McpError> {
let mut registry = self.mcp_registry.lock().await;
registry.auth_remove(name)
}
pub(crate) async fn mcp_connect(&self, name: &str) -> Result<bool, crate::mcp::McpError> {
let mut registry = self.mcp_registry.lock().await;
registry.connect(name).await
}
pub(crate) async fn mcp_disconnect(&self, name: &str) -> Result<bool, crate::mcp::McpError> {
let mut registry = self.mcp_registry.lock().await;
registry.disconnect(name).await
}
pub(crate) async fn mcp_tool_ids(&self) -> Vec<String> {
let registry = self.mcp_registry.lock().await;
registry.tool_ids()
}
pub(crate) async fn mcp_tool_list(&self) -> Vec<Value> {
let registry = self.mcp_registry.lock().await;
registry.tool_list()
}
pub(crate) async fn send_message(
self: &Arc<Self>,
session_id: String,

View file

@ -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<void>;
}
async function startMcpServer(): Promise<McpServerHandle> {
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<string>((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<void>((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",
}),
])
);
});
});

1
target Symbolic link
View file

@ -0,0 +1 @@
/home/nathan/sandbox-agent/target