mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-15 12:03:53 +00:00
feat: add MCP registry and OpenCode MCP endpoints
This commit is contained in:
parent
7378abee46
commit
2ae2020d40
9 changed files with 951 additions and 22 deletions
1
.turbo
Symbolic link
1
.turbo
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/.turbo
|
||||
1
dist
Symbolic link
1
dist
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/dist
|
||||
1
node_modules
Symbolic link
1
node_modules
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/node_modules
|
||||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
mod agent_server_logs;
|
||||
pub mod credentials;
|
||||
mod mcp;
|
||||
pub mod opencode_compat;
|
||||
pub mod router;
|
||||
pub mod server_logs;
|
||||
|
|
|
|||
581
server/packages/sandbox-agent/src/mcp.rs
Normal file
581
server/packages/sandbox-agent/src/mcp.rs
Normal 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),
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
194
server/packages/sandbox-agent/tests/opencode-compat/mcp.test.ts
Normal file
194
server/packages/sandbox-agent/tests/opencode-compat/mcp.test.ts
Normal 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
1
target
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
/home/nathan/sandbox-agent/target
|
||||
Loading…
Add table
Add a link
Reference in a new issue