mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-17 20:05:09 +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;
|
mod agent_server_logs;
|
||||||
pub mod credentials;
|
pub mod credentials;
|
||||||
|
mod mcp;
|
||||||
pub mod opencode_compat;
|
pub mod opencode_compat;
|
||||||
pub mod router;
|
pub mod router;
|
||||||
pub mod server_logs;
|
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 tokio::time::interval;
|
||||||
use utoipa::{IntoParams, OpenApi, ToSchema};
|
use utoipa::{IntoParams, OpenApi, ToSchema};
|
||||||
|
|
||||||
|
use crate::mcp::{McpConfig, McpError};
|
||||||
use crate::router::{AppState, CreateSessionRequest, PermissionReply};
|
use crate::router::{AppState, CreateSessionRequest, PermissionReply};
|
||||||
use sandbox_agent_error::SandboxError;
|
use sandbox_agent_error::SandboxError;
|
||||||
use sandbox_agent_agent_management::agents::AgentId;
|
use sandbox_agent_agent_management::agents::AgentId;
|
||||||
|
|
@ -555,6 +556,18 @@ struct PermissionGlobalReplyRequest {
|
||||||
reply: Option<String>,
|
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)]
|
#[derive(Debug, Serialize, Deserialize, ToSchema)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
struct QuestionReplyBody {
|
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> {
|
fn parse_permission_reply_value(value: Option<&str>) -> Result<PermissionReply, String> {
|
||||||
let value = value.unwrap_or("once").to_ascii_lowercase();
|
let value = value.unwrap_or("once").to_ascii_lowercase();
|
||||||
match value.as_str() {
|
match value.as_str() {
|
||||||
|
|
@ -3833,18 +3855,35 @@ async fn oc_find_symbols(Query(query): Query<FindSymbolsQuery>) -> impl IntoResp
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_mcp_list() -> impl IntoResponse {
|
async fn oc_mcp_list(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse {
|
||||||
(StatusCode::OK, Json(json!({})))
|
let status = state.inner.session_manager().mcp_status_map().await;
|
||||||
|
(StatusCode::OK, Json(status))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/mcp",
|
path = "/mcp",
|
||||||
|
request_body = McpRegisterRequest,
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_mcp_register() -> impl IntoResponse {
|
async fn oc_mcp_register(
|
||||||
(StatusCode::OK, Json(json!({})))
|
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(
|
#[utoipa::path(
|
||||||
|
|
@ -3855,10 +3894,13 @@ async fn oc_mcp_register() -> impl IntoResponse {
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_mcp_auth(
|
async fn oc_mcp_auth(
|
||||||
Path(_name): Path<String>,
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
_body: Option<Json<Value>>,
|
Path(name): Path<String>,
|
||||||
) -> impl IntoResponse {
|
) -> 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(
|
#[utoipa::path(
|
||||||
|
|
@ -3868,22 +3910,41 @@ async fn oc_mcp_auth(
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_mcp_auth_remove(Path(_name): Path<String>) -> impl IntoResponse {
|
async fn oc_mcp_auth_remove(
|
||||||
(StatusCode::OK, Json(json!({"status": "disabled"})))
|
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(
|
#[utoipa::path(
|
||||||
post,
|
post,
|
||||||
path = "/mcp/{name}/auth/callback",
|
path = "/mcp/{name}/auth/callback",
|
||||||
params(("name" = String, Path, description = "MCP server name")),
|
params(("name" = String, Path, description = "MCP server name")),
|
||||||
|
request_body = McpAuthCallbackRequest,
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_mcp_auth_callback(
|
async fn oc_mcp_auth_callback(
|
||||||
Path(_name): Path<String>,
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
_body: Option<Json<Value>>,
|
Path(name): Path<String>,
|
||||||
|
Json(body): Json<McpAuthCallbackRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> 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(
|
#[utoipa::path(
|
||||||
|
|
@ -3895,10 +3956,14 @@ async fn oc_mcp_auth_callback(
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_mcp_authenticate(
|
async fn oc_mcp_authenticate(
|
||||||
Path(_name): Path<String>,
|
State(state): State<Arc<OpenCodeAppState>>,
|
||||||
|
Path(name): Path<String>,
|
||||||
_body: Option<Json<Value>>,
|
_body: Option<Json<Value>>,
|
||||||
) -> impl IntoResponse {
|
) -> 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(
|
#[utoipa::path(
|
||||||
|
|
@ -3908,8 +3973,14 @@ async fn oc_mcp_authenticate(
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_mcp_connect(Path(_name): Path<String>) -> impl IntoResponse {
|
async fn oc_mcp_connect(
|
||||||
bool_ok(true)
|
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(
|
#[utoipa::path(
|
||||||
|
|
@ -3919,8 +3990,14 @@ async fn oc_mcp_connect(Path(_name): Path<String>) -> impl IntoResponse {
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_mcp_disconnect(Path(_name): Path<String>) -> impl IntoResponse {
|
async fn oc_mcp_disconnect(
|
||||||
bool_ok(true)
|
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(
|
#[utoipa::path(
|
||||||
|
|
@ -3929,8 +4006,9 @@ async fn oc_mcp_disconnect(Path(_name): Path<String>) -> impl IntoResponse {
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
tag = "opencode"
|
||||||
)]
|
)]
|
||||||
async fn oc_tool_ids() -> impl IntoResponse {
|
async fn oc_tool_ids(State(state): State<Arc<OpenCodeAppState>>) -> impl IntoResponse {
|
||||||
(StatusCode::OK, Json(json!([])))
|
let ids = state.inner.session_manager().mcp_tool_ids().await;
|
||||||
|
(StatusCode::OK, Json(ids))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
|
|
@ -3939,11 +4017,15 @@ async fn oc_tool_ids() -> impl IntoResponse {
|
||||||
responses((status = 200)),
|
responses((status = 200)),
|
||||||
tag = "opencode"
|
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() {
|
if query.provider.is_none() || query.model.is_none() {
|
||||||
return bad_request("provider and model are required").into_response();
|
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(
|
#[utoipa::path(
|
||||||
|
|
|
||||||
|
|
@ -818,6 +818,7 @@ pub(crate) struct SessionManager {
|
||||||
sessions: Mutex<Vec<SessionState>>,
|
sessions: Mutex<Vec<SessionState>>,
|
||||||
server_manager: Arc<AgentServerManager>,
|
server_manager: Arc<AgentServerManager>,
|
||||||
http_client: Client,
|
http_client: Client,
|
||||||
|
mcp_registry: Mutex<crate::mcp::McpRegistry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared Codex app-server process that handles multiple sessions via JSON-RPC.
|
/// Shared Codex app-server process that handles multiple sessions via JSON-RPC.
|
||||||
|
|
@ -1538,6 +1539,7 @@ impl SessionManager {
|
||||||
sessions: Mutex::new(Vec::new()),
|
sessions: Mutex::new(Vec::new()),
|
||||||
server_manager,
|
server_manager,
|
||||||
http_client: Client::new(),
|
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(
|
pub(crate) async fn send_message(
|
||||||
self: &Arc<Self>,
|
self: &Arc<Self>,
|
||||||
session_id: String,
|
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