Skip to main content

forge_server/
lib.rs

1#![warn(missing_docs)]
2
3//! # forge-server
4//!
5//! MCP server for the Forgemax Code Mode Gateway.
6//!
7//! Exposes exactly two tools to agents:
8//! - `search` — query the capability manifest to discover tools
9//! - `execute` — run code against the tool API
10//!
11//! This collapses N servers x M tools into a fixed ~1,000 token footprint.
12
13use 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/// The Forge MCP server handler.
26///
27/// Implements `ServerHandler` from rmcp to serve the `search` and `execute`
28/// Code Mode tools over MCP stdio or SSE transport.
29#[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    /// Create a new Forge server with the given config, manifest, and dispatcher.
40    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    /// Set a group policy for cross-server data flow enforcement.
55    ///
56    /// When set, each `execute()` call wraps the dispatcher with a fresh
57    /// [`GroupEnforcingDispatcher`] that tracks group access for that execution.
58    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/// Input for the `search` tool.
67#[derive(Debug, Deserialize, JsonSchema)]
68pub struct SearchInput {
69    /// JavaScript async arrow function to search the capability manifest.
70    /// The manifest is available as `globalThis.manifest` with servers,
71    /// categories, and tool schemas.
72    ///
73    /// IMPORTANT: `server.categories` is an Object keyed by name (NOT an array).
74    /// Use `Object.entries(s.categories)` or `Object.values(s.categories)` to iterate.
75    /// Each category has a `.tools` Array with `.name`, `.description`, `.input_schema`.
76    /// Check `input_schema.required` before calling a tool to get the right parameters.
77    pub code: String,
78}
79
80/// Input for the `execute` tool.
81#[derive(Debug, Deserialize, JsonSchema)]
82pub struct ExecuteInput {
83    /// JavaScript async arrow function to execute against the tool API.
84    /// Use `forge.callTool(server, tool, args)` or
85    /// `forge.server("name").category.tool(args)` to call tools.
86    ///
87    /// Runs in a sandboxed V8 isolate — no filesystem, network, or module access.
88    /// `import()`, `require()`, `eval()`, and `Deno.*` are all blocked.
89    pub code: String,
90}
91
92#[tool_router(router = tool_router)]
93impl ForgeServer {
94    /// Search the capability manifest to discover available tools across all
95    /// connected servers. The manifest is available as `globalThis.manifest`.
96    #[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    /// Execute code against the tool API in a sandboxed V8 isolate.
130    #[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        // Wrap dispatcher with group enforcement if a policy is configured.
141        // A fresh GroupEnforcingDispatcher is created per-execution so that
142        // group locking state doesn't leak between executions.
143        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        // Verify improved documentation is present
264        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                // eval( is a banned pattern
306                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}