1#![warn(missing_docs)]
2
3use std::sync::Arc;
14
15use forge_manifest::Manifest;
16use forge_sandbox::groups::{GroupEnforcingDispatcher, GroupPolicy};
17use forge_sandbox::{SandboxConfig, SandboxExecutor, ToolDispatcher};
18use rmcp::handler::server::router::tool::ToolRouter;
19use rmcp::handler::server::wrapper::Parameters;
20use rmcp::model::{Implementation, ServerCapabilities, ServerInfo};
21use rmcp::schemars::JsonSchema;
22use rmcp::{tool, tool_handler, tool_router, ServerHandler};
23use serde::Deserialize;
24
25#[derive(Clone)]
30pub struct ForgeServer {
31 executor: Arc<SandboxExecutor>,
32 manifest: Arc<Manifest>,
33 dispatcher: Arc<dyn ToolDispatcher>,
34 group_policy: Option<Arc<GroupPolicy>>,
35 tool_router: ToolRouter<Self>,
36}
37
38impl ForgeServer {
39 pub fn new(
41 config: SandboxConfig,
42 manifest: Manifest,
43 dispatcher: Arc<dyn ToolDispatcher>,
44 ) -> Self {
45 Self {
46 executor: Arc::new(SandboxExecutor::new(config)),
47 manifest: Arc::new(manifest),
48 dispatcher,
49 group_policy: None,
50 tool_router: Self::tool_router(),
51 }
52 }
53
54 pub fn with_group_policy(mut self, policy: GroupPolicy) -> Self {
59 if !policy.is_empty() {
60 self.group_policy = Some(Arc::new(policy));
61 }
62 self
63 }
64}
65
66#[derive(Debug, Deserialize, JsonSchema)]
68pub struct SearchInput {
69 pub code: String,
78}
79
80#[derive(Debug, Deserialize, JsonSchema)]
82pub struct ExecuteInput {
83 pub code: String,
90}
91
92#[tool_router(router = tool_router)]
93impl ForgeServer {
94 #[tool(
97 name = "search",
98 description = "Search the capability manifest to discover available tools across all connected servers. The manifest is available as `globalThis.manifest` with servers, categories, and tool schemas. Write a JavaScript async arrow function to query it.\n\nManifest structure: manifest.servers is an Array of {name, description, categories}. IMPORTANT: categories is an Object keyed by name (NOT an array) — use Object.entries() or Object.values() to iterate. Each category has a .tools Array with {name, description, input_schema}. Check input_schema for required parameters before calling a tool.\n\nExample: `async () => { const s = manifest.servers[0]; return Object.entries(s.categories).map(([name, cat]) => ({ name, tools: cat.tools.map(t => t.name) })); }`"
99 )]
100 pub async fn search(
101 &self,
102 Parameters(input): Parameters<SearchInput>,
103 ) -> Result<String, String> {
104 tracing::info!(code_len = input.code.len(), "search: starting");
105
106 let manifest_json = self
107 .manifest
108 .to_json()
109 .map_err(|e| format!("manifest serialization failed: {e}"))?;
110
111 match self
112 .executor
113 .execute_search(&input.code, &manifest_json)
114 .await
115 {
116 Ok(result) => {
117 let json = serde_json::to_string_pretty(&result)
118 .map_err(|e| format!("result serialization failed: {e}"))?;
119 tracing::info!(result_len = json.len(), "search: complete");
120 Ok(json)
121 }
122 Err(e) => {
123 tracing::warn!(error = %e, "search: failed");
124 Err(forge_sandbox::redact::redact_error_message(&format!("{e}")))
125 }
126 }
127 }
128
129 #[tool(
131 name = "execute",
132 description = "Execute JavaScript against the tool API. Use `forge.server('name').category.tool(args)` or `forge.callTool(server, tool, args)` to call tools on connected servers. Chain multiple operations in a single call.\n\nIMPORTANT: Code runs in a sandboxed V8 isolate with NO filesystem, network, or module access. import(), require(), eval(), and Deno.* are all blocked. Use forge.callTool() for all external operations.\n\nExample: `async () => { const result = await forge.callTool('narsil', 'scan_security', { repo: 'MyProject' }); return result; }`"
133 )]
134 pub async fn execute(
135 &self,
136 Parameters(input): Parameters<ExecuteInput>,
137 ) -> Result<String, String> {
138 tracing::info!(code_len = input.code.len(), "execute: starting");
139
140 let dispatcher: Arc<dyn ToolDispatcher> = match &self.group_policy {
144 Some(policy) => Arc::new(GroupEnforcingDispatcher::new(
145 self.dispatcher.clone(),
146 policy.clone(),
147 )),
148 None => self.dispatcher.clone(),
149 };
150
151 match self.executor.execute_code(&input.code, dispatcher).await {
152 Ok(result) => {
153 let json = serde_json::to_string_pretty(&result)
154 .map_err(|e| format!("result serialization failed: {e}"))?;
155 tracing::info!(result_len = json.len(), "execute: complete");
156 Ok(json)
157 }
158 Err(e) => {
159 tracing::warn!(error = %e, "execute: failed");
160 Err(forge_sandbox::redact::redact_error_message(&format!("{e}")))
161 }
162 }
163 }
164}
165
166#[tool_handler(router = self.tool_router)]
167impl ServerHandler for ForgeServer {
168 fn get_info(&self) -> ServerInfo {
169 let stats = format!(
170 "{} servers, {} tools",
171 self.manifest.total_servers(),
172 self.manifest.total_tools(),
173 );
174
175 ServerInfo {
176 capabilities: ServerCapabilities::builder().enable_tools().build(),
177 instructions: Some(format!(
178 "Forgemax Code Mode Gateway ({stats}). \
179 Use search() to discover available tools, then execute() to call them.\n\
180 \n\
181 Both tools take a `code` parameter containing a JavaScript async arrow function.\n\
182 Example: `async () => {{ return manifest.servers.map(s => s.name); }}`\n\
183 \n\
184 Manifest shape:\n\
185 - manifest.servers: Array of {{ name, description, categories }}\n\
186 - server.categories: Object (NOT array) keyed by category name, e.g. categories[\"ast\"]\n\
187 - Use Object.entries(s.categories) or Object.values(s.categories) to iterate categories\n\
188 - Each category has .tools (Array) with .name, .description, .input_schema\n\
189 - Always check a tool's input_schema.required before calling it\n\
190 \n\
191 Sandboxed environment — no filesystem, network, or module imports (import/require/eval are blocked). \
192 Use forge.callTool(server, tool, args) for all external operations."
193 )),
194 server_info: Implementation {
195 name: "forge".into(),
196 version: env!("CARGO_PKG_VERSION").into(),
197 title: None,
198 description: None,
199 icons: None,
200 website_url: None,
201 },
202 ..Default::default()
203 }
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use forge_manifest::{Category, ManifestBuilder, ServerBuilder, ToolEntry};
211
212 struct TestDispatcher;
213
214 #[async_trait::async_trait]
215 impl ToolDispatcher for TestDispatcher {
216 async fn call_tool(
217 &self,
218 server: &str,
219 tool: &str,
220 args: serde_json::Value,
221 ) -> Result<serde_json::Value, anyhow::Error> {
222 Ok(serde_json::json!({
223 "server": server,
224 "tool": tool,
225 "args": args,
226 "status": "ok"
227 }))
228 }
229 }
230
231 fn test_server() -> ForgeServer {
232 let manifest = ManifestBuilder::new()
233 .add_server(
234 ServerBuilder::new("test-server", "A test server")
235 .add_category(Category {
236 name: "tools".into(),
237 description: "Test tools".into(),
238 tools: vec![ToolEntry {
239 name: "echo".into(),
240 description: "Echoes input".into(),
241 params: vec![],
242 returns: Some("The input".into()),
243 input_schema: None,
244 }],
245 })
246 .build(),
247 )
248 .build();
249 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
250 ForgeServer::new(SandboxConfig::default(), manifest, dispatcher)
251 }
252
253 #[test]
254 fn get_info_returns_correct_metadata() {
255 let server = test_server();
256 let info = server.get_info();
257 assert_eq!(info.server_info.name, "forge");
258 assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
259 let instructions = info.instructions.unwrap();
260 assert!(instructions.contains("search()"));
261 assert!(instructions.contains("execute()"));
262 assert!(instructions.contains("1 servers, 1 tools"));
263 assert!(
265 instructions.contains("async arrow function"),
266 "instructions should mention async arrow function format"
267 );
268 assert!(
269 instructions.contains("Object (NOT array)"),
270 "instructions should warn about categories being an Object"
271 );
272 assert!(
273 instructions.contains("input_schema"),
274 "instructions should mention input_schema for parameter discovery"
275 );
276 assert!(
277 instructions.contains("no filesystem"),
278 "instructions should mention sandbox constraints"
279 );
280 }
281
282 #[tokio::test]
283 async fn search_returns_json() {
284 let server = test_server();
285 let result = server
286 .search(Parameters(SearchInput {
287 code: r#"async () => { return manifest.servers.map(s => s.name); }"#.into(),
288 }))
289 .await;
290 match result {
291 Ok(json) => {
292 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
293 let names = parsed.as_array().unwrap();
294 assert_eq!(names[0], "test-server");
295 }
296 Err(e) => panic!("search should succeed: {e}"),
297 }
298 }
299
300 #[tokio::test]
301 async fn search_with_invalid_code_returns_error() {
302 let server = test_server();
303 let result = server
304 .search(Parameters(SearchInput {
305 code: r#"async () => { return eval("bad"); }"#.into(),
307 }))
308 .await;
309 assert!(result.is_err(), "search with banned code should fail");
310 assert!(result.unwrap_err().contains("banned pattern"));
311 }
312
313 #[tokio::test]
314 async fn execute_calls_tool() {
315 let server = test_server();
316 let result = server
317 .execute(Parameters(ExecuteInput {
318 code: r#"async () => {
319 return await forge.callTool("test-server", "tools.echo", { msg: "hi" });
320 }"#
321 .into(),
322 }))
323 .await;
324 match result {
325 Ok(json) => {
326 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
327 assert_eq!(parsed["server"], "test-server");
328 assert_eq!(parsed["tool"], "tools.echo");
329 assert_eq!(parsed["status"], "ok");
330 }
331 Err(e) => panic!("execute should succeed: {e}"),
332 }
333 }
334
335 #[tokio::test]
336 async fn execute_with_banned_code_returns_error() {
337 let server = test_server();
338 let result = server
339 .execute(Parameters(ExecuteInput {
340 code: r#"async () => { return eval("bad"); }"#.into(),
341 }))
342 .await;
343 assert!(result.is_err(), "execute with banned code should fail");
344 assert!(result.unwrap_err().contains("banned pattern"));
345 }
346
347 #[tokio::test]
348 async fn empty_code_returns_error() {
349 let server = test_server();
350 let result = server
351 .search(Parameters(SearchInput { code: " ".into() }))
352 .await;
353 assert!(result.is_err(), "empty code should fail");
354 assert!(result.unwrap_err().contains("empty"));
355 }
356}