use std::sync::{Arc, Mutex};
use forge_manifest::{Category, ManifestBuilder, ServerBuilder, ToolEntry};
use forge_sandbox::groups::GroupPolicy;
use forge_sandbox::stash::StashConfig;
use forge_sandbox::{ResourceDispatcher, SandboxConfig, ToolDispatcher};
use forge_server::{ExecuteInput, ForgeServer, SearchInput};
use rmcp::handler::server::wrapper::Parameters;
use rmcp::ServerHandler;
struct RecordingDispatcher {
calls: Mutex<Vec<(String, String, serde_json::Value)>>,
}
impl RecordingDispatcher {
fn new() -> Self {
Self {
calls: Mutex::new(Vec::new()),
}
}
fn recorded_calls(&self) -> Vec<(String, String, serde_json::Value)> {
self.calls.lock().unwrap().clone()
}
}
#[async_trait::async_trait]
impl ToolDispatcher for RecordingDispatcher {
async fn call_tool(
&self,
server: &str,
tool: &str,
args: serde_json::Value,
) -> Result<serde_json::Value, forge_error::DispatchError> {
self.calls
.lock()
.unwrap()
.push((server.to_string(), tool.to_string(), args.clone()));
Ok(serde_json::json!({
"server": server,
"tool": tool,
"result": "recorded",
}))
}
}
fn demo_manifest() -> forge_manifest::Manifest {
ManifestBuilder::new()
.add_server(
ServerBuilder::new("narsil", "Code intelligence")
.add_category(Category {
name: "ast".into(),
description: "AST tools".into(),
tools: vec![
ToolEntry {
name: "parse".into(),
description: "Parse source code".into(),
params: vec![],
returns: Some("AST".into()),
input_schema: None,
},
ToolEntry {
name: "query".into(),
description: "Query AST".into(),
params: vec![],
returns: None,
input_schema: None,
},
],
})
.add_category(Category {
name: "symbols".into(),
description: "Symbol tools".into(),
tools: vec![ToolEntry {
name: "find".into(),
description: "Find symbols".into(),
params: vec![],
returns: None,
input_schema: None,
}],
})
.build(),
)
.build()
}
fn test_server_with_dispatcher(dispatcher: Arc<dyn ToolDispatcher>) -> ForgeServer {
ForgeServer::new(SandboxConfig::default(), demo_manifest(), dispatcher, None)
}
#[tokio::test]
async fn full_stack_search_then_execute() {
let dispatcher = Arc::new(RecordingDispatcher::new());
let server = test_server_with_dispatcher(dispatcher.clone());
let search_result = server
.search(Parameters(SearchInput {
code: r#"async () => {
return manifest.servers.map(s => ({
name: s.name,
tools: Object.values(s.categories)
.flatMap(c => c.tools.map(t => t.name))
}));
}"#
.into(),
}))
.await;
let search_json = search_result.expect("search should succeed");
let parsed: serde_json::Value = serde_json::from_str(&search_json).unwrap();
let servers = parsed.as_array().unwrap();
assert_eq!(servers[0]["name"], "narsil");
let exec_result = server
.execute(Parameters(ExecuteInput {
code: r#"async () => {
const result = await forge.server("narsil").ast.parse({ file: "main.rs" });
return result;
}"#
.into(),
}))
.await;
let exec_json = exec_result.expect("execute should succeed");
let parsed: serde_json::Value = serde_json::from_str(&exec_json).unwrap();
assert_eq!(parsed["server"], "narsil");
assert_eq!(parsed["tool"], "ast.parse");
assert_eq!(parsed["result"], "recorded");
let calls = dispatcher.recorded_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].0, "narsil");
assert_eq!(calls[0].1, "ast.parse");
assert_eq!(calls[0].2["file"], "main.rs");
}
#[tokio::test]
async fn recording_dispatcher_captures_multiple_calls() {
let dispatcher = Arc::new(RecordingDispatcher::new());
let server = test_server_with_dispatcher(dispatcher.clone());
let result = server
.execute(Parameters(ExecuteInput {
code: r#"async () => {
const r1 = await forge.callTool("narsil", "ast.parse", { file: "a.rs" });
const r2 = await forge.callTool("narsil", "symbols.find", { pattern: "main" });
return [r1, r2];
}"#
.into(),
}))
.await;
assert!(result.is_ok(), "execute should succeed: {:?}", result);
let calls = dispatcher.recorded_calls();
assert_eq!(calls.len(), 2);
assert_eq!(calls[0].1, "ast.parse");
assert_eq!(calls[0].2["file"], "a.rs");
assert_eq!(calls[1].1, "symbols.find");
assert_eq!(calls[1].2["pattern"], "main");
}
#[tokio::test]
async fn error_propagation_from_dispatcher() {
struct FailingDispatcher;
#[async_trait::async_trait]
impl ToolDispatcher for FailingDispatcher {
async fn call_tool(
&self,
_server: &str,
_tool: &str,
_args: serde_json::Value,
) -> Result<serde_json::Value, forge_error::DispatchError> {
Err(forge_error::DispatchError::Internal(anyhow::anyhow!(
"simulated downstream failure"
)))
}
}
let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(FailingDispatcher);
let server = test_server_with_dispatcher(dispatcher);
let result = server
.execute(Parameters(ExecuteInput {
code: r#"async () => {
// Structured errors are returned as values, not thrown
const result = await forge.callTool("narsil", "ast.parse", {});
return result;
}"#
.into(),
}))
.await;
let json = result.expect("execute should succeed");
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed["error"], true,
"should be structured error: {parsed:?}"
);
assert!(
parsed["message"]
.as_str()
.unwrap()
.contains("simulated downstream failure"),
"error should propagate: {parsed:?}"
);
}
#[tokio::test]
async fn get_info_reflects_manifest_stats() {
let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(RecordingDispatcher::new());
let server = test_server_with_dispatcher(dispatcher);
let info = server.get_info();
assert_eq!(info.server_info.name, "forge");
let instructions = info.instructions.unwrap();
assert!(
instructions.contains("1 servers, 3 tools"),
"instructions should reflect manifest stats: {instructions}"
);
}
struct MockResourceDispatcher {
content: serde_json::Value,
}
impl MockResourceDispatcher {
fn with_content(content: serde_json::Value) -> Self {
Self { content }
}
}
#[async_trait::async_trait]
impl ResourceDispatcher for MockResourceDispatcher {
async fn read_resource(
&self,
server: &str,
uri: &str,
) -> Result<serde_json::Value, forge_error::DispatchError> {
Ok(serde_json::json!({
"server": server,
"uri": uri,
"content": self.content,
}))
}
}
struct FailingResourceDispatcher {
error_msg: String,
}
#[async_trait::async_trait]
impl ResourceDispatcher for FailingResourceDispatcher {
async fn read_resource(
&self,
_server: &str,
_uri: &str,
) -> Result<serde_json::Value, forge_error::DispatchError> {
Err(forge_error::DispatchError::Internal(anyhow::anyhow!(
"{}",
self.error_msg
)))
}
}
fn test_server_with_resources(
dispatcher: Arc<dyn ToolDispatcher>,
resource_dispatcher: Arc<dyn ResourceDispatcher>,
) -> ForgeServer {
ForgeServer::new(
SandboxConfig::default(),
demo_manifest(),
dispatcher,
Some(resource_dispatcher),
)
.with_stash(StashConfig::default())
}
#[tokio::test]
async fn rs_i01_read_resource_from_mock_server() {
let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(RecordingDispatcher::new());
let resource: Arc<dyn ResourceDispatcher> =
Arc::new(MockResourceDispatcher::with_content(serde_json::json!({
"log_lines": ["INFO: started", "ERROR: connection refused", "INFO: retry ok"]
})));
let server = test_server_with_resources(dispatcher, resource);
let result = server
.execute(Parameters(ExecuteInput {
code: r#"async () => {
const data = await forge.readResource("narsil", "file:///var/log/app.log");
return data;
}"#
.into(),
}))
.await;
let json = result.expect("execute should succeed");
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["server"], "narsil");
assert_eq!(parsed["uri"], "file:///var/log/app.log");
assert_eq!(
parsed["content"]["log_lines"][1],
"ERROR: connection refused"
);
}
#[tokio::test]
async fn rs_i02_read_resource_with_js_filter() {
let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(RecordingDispatcher::new());
let entries: Vec<serde_json::Value> = (0..100)
.map(|i| {
serde_json::json!({
"id": i,
"level": if i % 10 == 0 { "ERROR" } else { "INFO" },
"msg": format!("log entry {i}")
})
})
.collect();
let resource: Arc<dyn ResourceDispatcher> = Arc::new(MockResourceDispatcher::with_content(
serde_json::json!(entries),
));
let server = test_server_with_resources(dispatcher, resource);
let result = server
.execute(Parameters(ExecuteInput {
code: r#"async () => {
const data = await forge.readResource("narsil", "logs://app/recent");
// Filter to only ERROR entries
const errors = data.content.filter(e => e.level === "ERROR");
return { count: errors.length, first_error: errors[0] };
}"#
.into(),
}))
.await;
let json = result.expect("execute should succeed");
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["count"], 10);
assert_eq!(parsed["first_error"]["id"], 0);
assert_eq!(parsed["first_error"]["level"], "ERROR");
}
#[tokio::test]
async fn rs_i03_read_resource_group_isolation() {
let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(RecordingDispatcher::new());
let resource: Arc<dyn ResourceDispatcher> = Arc::new(MockResourceDispatcher::with_content(
serde_json::json!("data"),
));
let mut groups = std::collections::HashMap::new();
groups.insert(
"intel".to_string(),
(vec!["narsil".to_string()], "strict".to_string()),
);
groups.insert(
"data".to_string(),
(vec!["other-server".to_string()], "strict".to_string()),
);
let policy = GroupPolicy::from_config(&groups);
let server = ForgeServer::new(
SandboxConfig::default(),
demo_manifest(),
dispatcher,
Some(resource),
)
.with_group_policy(policy)
.with_stash(StashConfig::default());
let result = server
.execute(Parameters(ExecuteInput {
code: r#"async () => {
// First read from narsil — locks to group "intel"
const data = await forge.readResource("narsil", "file:///log");
// Now try to call a tool on "other-server" (group "data")
// Structured errors are returned as values, not thrown
const result = await forge.callTool("other-server", "tool", {});
return result;
}"#
.into(),
}))
.await;
let json = result.expect("execute should succeed");
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed["error"], true,
"should be structured error: {parsed:?}"
);
assert_eq!(
parsed["code"].as_str().unwrap(),
"GROUP_POLICY_DENIED",
"should report group policy denied, got: {parsed:?}"
);
}
#[tokio::test]
async fn rs_i04_child_process_config_wiring() {
let config = SandboxConfig {
execution_mode: forge_sandbox::ExecutionMode::ChildProcess,
..Default::default()
};
assert_eq!(
config.execution_mode,
forge_sandbox::ExecutionMode::ChildProcess
);
}
#[tokio::test]
async fn rs_i05_combined_read_resource_and_call_tool() {
let dispatcher = Arc::new(RecordingDispatcher::new());
let resource: Arc<dyn ResourceDispatcher> = Arc::new(MockResourceDispatcher::with_content(
serde_json::json!({"schema": {"tables": ["users", "orders"]}}),
));
let server = test_server_with_resources(dispatcher.clone(), resource);
let result = server
.execute(Parameters(ExecuteInput {
code: r#"async () => {
// Read resource to discover schema
const schema = await forge.readResource("narsil", "db://schema");
const tables = schema.content.schema.tables;
// Call tool based on discovered schema
const result = await forge.callTool("narsil", "ast.query", {
table: tables[0]
});
return {
tables_found: tables.length,
query_server: result.server,
query_tool: result.tool,
};
}"#
.into(),
}))
.await;
let json = result.expect("execute should succeed");
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["tables_found"], 2);
assert_eq!(parsed["query_server"], "narsil");
assert_eq!(parsed["query_tool"], "ast.query");
let calls = dispatcher.recorded_calls();
assert_eq!(calls.len(), 1);
assert_eq!(calls[0].2["table"], "users");
}
#[tokio::test]
async fn rs_i06_read_resource_timeout_enforcement() {
struct SlowResourceDispatcher;
#[async_trait::async_trait]
impl ResourceDispatcher for SlowResourceDispatcher {
async fn read_resource(
&self,
_server: &str,
_uri: &str,
) -> Result<serde_json::Value, forge_error::DispatchError> {
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
Ok(serde_json::json!({"data": "too late"}))
}
}
let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(RecordingDispatcher::new());
let resource: Arc<dyn ResourceDispatcher> = Arc::new(SlowResourceDispatcher);
let server = ForgeServer::new(
SandboxConfig {
timeout: std::time::Duration::from_millis(500),
..Default::default()
},
demo_manifest(),
dispatcher,
Some(resource),
);
let result = server
.execute(Parameters(ExecuteInput {
code: r#"async () => {
const data = await forge.readResource("narsil", "file:///slow");
return data;
}"#
.into(),
}))
.await;
assert!(
result.is_ok(),
"should return Ok with error JSON, got: {:?}",
result
);
let json = result.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let error_msg = parsed["error"]
.as_str()
.expect("should have an error field");
assert!(
error_msg == "async timeout" || error_msg.contains("timed out"),
"should report a timeout error, got: {error_msg:?}"
);
}
#[tokio::test]
async fn rs_i07_graceful_degradation_no_resource_support() {
let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(RecordingDispatcher::new());
let resource: Arc<dyn ResourceDispatcher> = Arc::new(FailingResourceDispatcher {
error_msg: "method not found: resources/read".into(),
});
let server = test_server_with_resources(dispatcher, resource);
let result = server
.execute(Parameters(ExecuteInput {
code: r#"async () => {
// Structured errors are returned as values, not thrown
const result = await forge.readResource("narsil", "file:///log");
return result;
}"#
.into(),
}))
.await;
let json = result.expect("execute should succeed");
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
parsed["error"], true,
"should be structured error: {parsed:?}"
);
let error = parsed["message"].as_str().unwrap();
assert!(
!error.is_empty(),
"should have a non-empty error message for unsupported resources"
);
}