1#![warn(missing_docs)]
2
3use std::sync::Arc;
14use std::time::Duration;
15
16use forge_manifest::{LiveManifest, Manifest};
17use forge_sandbox::groups::{
18 GroupEnforcingDispatcher, GroupEnforcingResourceDispatcher, GroupPolicy,
19};
20use forge_sandbox::stash::{SessionStash, StashConfig};
21use forge_sandbox::{
22 ResourceDispatcher, SandboxConfig, SandboxExecutor, StashDispatcher, ToolDispatcher,
23};
24use rmcp::handler::server::router::tool::ToolRouter;
25use rmcp::handler::server::wrapper::Parameters;
26use rmcp::model::{Implementation, ServerCapabilities, ServerInfo};
27use rmcp::schemars::JsonSchema;
28use rmcp::{tool, tool_handler, tool_router, ServerHandler};
29use serde::Deserialize;
30
31const MAX_RESULT_CHARS: usize = 100_000;
37
38fn truncate_result_if_needed(json: String) -> String {
44 if json.len() <= MAX_RESULT_CHARS {
45 return json;
46 }
47 let shown = MAX_RESULT_CHARS.saturating_sub(200);
48 let end = json[..shown]
49 .char_indices()
50 .last()
51 .map(|(i, c)| i + c.len_utf8())
52 .unwrap_or(0);
53 serde_json::json!({
54 "_truncated": true,
55 "_original_chars": json.len(),
56 "_shown_chars": end,
57 "data": &json[..end]
58 })
59 .to_string()
60}
61
62#[derive(Clone)]
67pub struct ForgeServer {
68 executor: Arc<SandboxExecutor>,
69 manifest: LiveManifest,
70 dispatcher: Arc<dyn ToolDispatcher>,
71 resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
72 group_policy: Option<Arc<GroupPolicy>>,
73 session_stash: Option<Arc<tokio::sync::Mutex<SessionStash>>>,
74 tool_router: ToolRouter<Self>,
75}
76
77struct ServerStashDispatcher {
82 stash: Arc<tokio::sync::Mutex<SessionStash>>,
83 current_group: Option<String>,
84}
85
86#[async_trait::async_trait]
87impl StashDispatcher for ServerStashDispatcher {
88 async fn put(
89 &self,
90 key: &str,
91 value: serde_json::Value,
92 ttl_secs: Option<u32>,
93 _current_group: Option<String>,
94 ) -> Result<serde_json::Value, forge_error::DispatchError> {
95 let ttl = ttl_secs
96 .filter(|&s| s > 0)
97 .map(|s| Duration::from_secs(s as u64));
98 let mut stash = self.stash.lock().await;
99 stash
100 .put(key, value, ttl, self.current_group.as_deref())
101 .map_err(|e| forge_error::DispatchError::Internal(e.into()))?;
102 Ok(serde_json::json!({"ok": true}))
103 }
104
105 async fn get(
106 &self,
107 key: &str,
108 _current_group: Option<String>,
109 ) -> Result<serde_json::Value, forge_error::DispatchError> {
110 let stash = self.stash.lock().await;
111 match stash
112 .get(key, self.current_group.as_deref())
113 .map_err(|e| forge_error::DispatchError::Internal(e.into()))?
114 {
115 Some(v) => Ok(v.clone()),
116 None => Ok(serde_json::Value::Null),
117 }
118 }
119
120 async fn delete(
121 &self,
122 key: &str,
123 _current_group: Option<String>,
124 ) -> Result<serde_json::Value, forge_error::DispatchError> {
125 let mut stash = self.stash.lock().await;
126 let deleted = stash
127 .delete(key, self.current_group.as_deref())
128 .map_err(|e| forge_error::DispatchError::Internal(e.into()))?;
129 Ok(serde_json::json!({"deleted": deleted}))
130 }
131
132 async fn keys(
133 &self,
134 _current_group: Option<String>,
135 ) -> Result<serde_json::Value, forge_error::DispatchError> {
136 let stash = self.stash.lock().await;
137 let keys: Vec<&str> = stash.keys(self.current_group.as_deref());
138 Ok(serde_json::json!(keys))
139 }
140}
141
142impl ForgeServer {
143 pub fn new(
146 config: SandboxConfig,
147 manifest: Manifest,
148 dispatcher: Arc<dyn ToolDispatcher>,
149 resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
150 ) -> Self {
151 Self {
152 executor: Arc::new(SandboxExecutor::new(config)),
153 manifest: LiveManifest::new(manifest),
154 dispatcher,
155 resource_dispatcher,
156 group_policy: None,
157 session_stash: None,
158 tool_router: Self::tool_router(),
159 }
160 }
161
162 pub fn with_group_policy(mut self, policy: GroupPolicy) -> Self {
169 if !policy.is_empty() {
170 self.group_policy = Some(Arc::new(policy));
171 }
172 self
173 }
174
175 pub fn with_stash(mut self, config: StashConfig) -> Self {
180 self.session_stash = Some(Arc::new(tokio::sync::Mutex::new(SessionStash::new(config))));
181 self
182 }
183
184 pub fn new_with_executor(
189 executor: SandboxExecutor,
190 manifest: Manifest,
191 dispatcher: Arc<dyn ToolDispatcher>,
192 resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
193 ) -> Self {
194 Self {
195 executor: Arc::new(executor),
196 manifest: LiveManifest::new(manifest),
197 dispatcher,
198 resource_dispatcher,
199 group_policy: None,
200 session_stash: None,
201 tool_router: Self::tool_router(),
202 }
203 }
204
205 pub fn live_manifest(&self) -> &LiveManifest {
210 &self.manifest
211 }
212}
213
214#[derive(Debug, Deserialize, JsonSchema)]
216pub struct SearchInput {
217 pub code: String,
226}
227
228#[derive(Debug, Deserialize, JsonSchema)]
230pub struct ExecuteInput {
231 pub code: String,
238}
239
240#[tool_router(router = tool_router)]
241impl ForgeServer {
242 #[tool(
245 name = "search",
246 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) })); }`"
247 )]
248 #[tracing::instrument(skip(self, input), fields(code_len = input.code.len()))]
249 pub async fn search(
250 &self,
251 Parameters(input): Parameters<SearchInput>,
252 ) -> Result<String, String> {
253 tracing::info!("search: starting");
254
255 let manifest = self.manifest.current();
257 let manifest_json = manifest
258 .to_json()
259 .map_err(|e| format!("manifest serialization failed: {e}"))?;
260
261 match self
262 .executor
263 .execute_search(&input.code, &manifest_json)
264 .await
265 {
266 Ok(result) => {
267 let json = serde_json::to_string_pretty(&result)
268 .map_err(|e| format!("result serialization failed: {e}"))?;
269 tracing::info!(result_len = json.len(), "search: complete");
270 Ok(truncate_result_if_needed(json))
271 }
272 Err(e) => {
273 tracing::warn!(error = %e, "search: failed");
274 let msg = format!("{e}");
275 let clean = msg.strip_prefix("javascript error: ").unwrap_or(&msg);
278 Ok(serde_json::json!({"error": clean}).to_string())
279 }
280 }
281 }
282
283 #[tool(
285 name = "execute",
286 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; }`\n\nAdditional APIs:\n- `forge.readResource(server, uri)` — read MCP resources\n- `forge.stash.put(key, value, {ttl?})` / `.get(key)` / `.delete(key)` / `.keys()` — session key-value store\n- `forge.parallel(calls, opts)` — bounded concurrent execution\n\nAlways check tool input_schema via search() before calling unfamiliar tools."
287 )]
288 #[tracing::instrument(skip(self, input), fields(code_len = input.code.len()))]
289 pub async fn execute(
290 &self,
291 Parameters(input): Parameters<ExecuteInput>,
292 ) -> Result<String, String> {
293 tracing::info!("execute: starting");
294
295 let (dispatcher, resource_dispatcher): (
300 Arc<dyn ToolDispatcher>,
301 Option<Arc<dyn ResourceDispatcher>>,
302 ) = match &self.group_policy {
303 Some(policy) => {
304 let tool_enforcer =
305 GroupEnforcingDispatcher::new(self.dispatcher.clone(), policy.clone());
306 let shared_lock = tool_enforcer.shared_lock();
307
308 let resource = self.resource_dispatcher.as_ref().map(|rd| {
309 Arc::new(GroupEnforcingResourceDispatcher::new(
310 rd.clone(),
311 policy.clone(),
312 shared_lock,
313 )) as Arc<dyn ResourceDispatcher>
314 });
315
316 (Arc::new(tool_enforcer), resource)
317 }
318 None => (self.dispatcher.clone(), self.resource_dispatcher.clone()),
319 };
320
321 let stash_dispatcher: Option<Arc<dyn StashDispatcher>> =
323 self.session_stash.as_ref().map(|stash| {
324 Arc::new(ServerStashDispatcher {
325 stash: stash.clone(),
326 current_group: None, }) as Arc<dyn StashDispatcher>
328 });
329
330 let manifest = self.manifest.current();
332
333 let known_servers: std::collections::HashSet<String> =
335 manifest.servers.iter().map(|s| s.name.clone()).collect();
336
337 let known_tools: Vec<(String, String)> = manifest
339 .servers
340 .iter()
341 .flat_map(|s| {
342 s.categories
343 .values()
344 .flat_map(|cat| cat.tools.iter().map(|t| (s.name.clone(), t.name.clone())))
345 })
346 .collect();
347
348 match self
349 .executor
350 .execute_code_with_options(
351 &input.code,
352 dispatcher,
353 resource_dispatcher,
354 stash_dispatcher,
355 Some(known_servers),
356 Some(known_tools),
357 )
358 .await
359 {
360 Ok(result) => {
361 let json = serde_json::to_string_pretty(&result)
362 .map_err(|e| format!("result serialization failed: {e}"))?;
363 tracing::info!(result_len = json.len(), "execute: complete");
364 Ok(truncate_result_if_needed(json))
365 }
366 Err(e) => {
367 tracing::warn!(error = %e, "execute: failed");
368 let msg = format!("{e}");
369 let clean = msg.strip_prefix("javascript error: ").unwrap_or(&msg);
372 Ok(serde_json::json!({"error": clean}).to_string())
373 }
374 }
375 }
376}
377
378#[tool_handler(router = self.tool_router)]
379impl ServerHandler for ForgeServer {
380 fn get_info(&self) -> ServerInfo {
381 let manifest = self.manifest.current();
382 let stats = format!(
383 "{} servers, {} tools",
384 manifest.total_servers(),
385 manifest.total_tools(),
386 );
387
388 ServerInfo {
389 capabilities: ServerCapabilities::builder().enable_tools().build(),
390 instructions: Some(format!(
391 "Forgemax Code Mode Gateway ({stats}). \
392 Use search() to discover available tools, then execute() to call them.\n\
393 \n\
394 Both tools take a `code` parameter containing a JavaScript async arrow function.\n\
395 Example: `async () => {{ return manifest.servers.map(s => s.name); }}`\n\
396 \n\
397 Manifest shape:\n\
398 - manifest.servers: Array of {{ name, description, categories }}\n\
399 - server.categories: Object (NOT array) keyed by category name, e.g. categories[\"ast\"]\n\
400 - Use Object.entries(s.categories) or Object.values(s.categories) to iterate categories\n\
401 - Each category has .tools (Array) with .name, .description, .input_schema\n\
402 - Always check a tool's input_schema.required before calling it\n\
403 \n\
404 Sandboxed environment — no filesystem, network, or module imports (import/require/eval are blocked). \
405 Use forge.callTool(server, tool, args) for all external operations.\n\
406 \n\
407 When calling tools, use the tool name only (e.g. \"find_symbols\"), \
408 not the category-prefixed form (e.g. NOT \"general.find_symbols\").\n\
409 \n\
410 Additional APIs (execute mode only):\n\
411 - forge.readResource(server, uri) — read MCP resources from downstream servers\n\
412 - forge.stash.put(key, value, {{ttl?}}) / .get(key) / .delete(key) / .keys() — \
413 session-scoped key-value store for sharing data across executions\n\
414 - forge.parallel(calls, opts) — bounded concurrent execution of tool/resource calls\n\
415 \n\
416 ## TypeScript API Definitions\n\
417 \n\
418 ```typescript\n\
419 {dts}\n\
420 ```",
421 dts = forge_manifest::FORGE_DTS
422 )),
423 server_info: Implementation {
424 name: "forge".into(),
425 version: env!("CARGO_PKG_VERSION").into(),
426 title: None,
427 description: None,
428 icons: None,
429 website_url: None,
430 },
431 ..Default::default()
432 }
433 }
434}
435
436#[cfg(test)]
437mod tests {
438 use super::*;
439 use forge_manifest::{Category, ManifestBuilder, ServerBuilder, ToolEntry};
440
441 struct TestDispatcher;
442
443 #[async_trait::async_trait]
444 impl ToolDispatcher for TestDispatcher {
445 async fn call_tool(
446 &self,
447 server: &str,
448 tool: &str,
449 args: serde_json::Value,
450 ) -> Result<serde_json::Value, forge_error::DispatchError> {
451 Ok(serde_json::json!({
452 "server": server,
453 "tool": tool,
454 "args": args,
455 "status": "ok"
456 }))
457 }
458 }
459
460 fn test_server() -> ForgeServer {
461 let manifest = ManifestBuilder::new()
462 .add_server(
463 ServerBuilder::new("test-server", "A test server")
464 .add_category(Category {
465 name: "tools".into(),
466 description: "Test tools".into(),
467 tools: vec![ToolEntry {
468 name: "echo".into(),
469 description: "Echoes input".into(),
470 params: vec![],
471 returns: Some("The input".into()),
472 input_schema: None,
473 }],
474 })
475 .build(),
476 )
477 .build();
478 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
479 ForgeServer::new(SandboxConfig::default(), manifest, dispatcher, None)
480 }
481
482 #[test]
483 fn get_info_returns_correct_metadata() {
484 let server = test_server();
485 let info = server.get_info();
486 assert_eq!(info.server_info.name, "forge");
487 assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
488 let instructions = info.instructions.unwrap();
489 assert!(instructions.contains("search()"));
490 assert!(instructions.contains("execute()"));
491 assert!(instructions.contains("1 servers, 1 tools"));
492 assert!(
494 instructions.contains("async arrow function"),
495 "instructions should mention async arrow function format"
496 );
497 assert!(
498 instructions.contains("Object (NOT array)"),
499 "instructions should warn about categories being an Object"
500 );
501 assert!(
502 instructions.contains("input_schema"),
503 "instructions should mention input_schema for parameter discovery"
504 );
505 assert!(
506 instructions.contains("no filesystem"),
507 "instructions should mention sandbox constraints"
508 );
509 assert!(
510 instructions.contains("use the tool name only"),
511 "instructions should clarify tool name vs category-prefixed form"
512 );
513 }
514
515 #[tokio::test]
516 async fn search_returns_json() {
517 let server = test_server();
518 let result = server
519 .search(Parameters(SearchInput {
520 code: r#"async () => { return manifest.servers.map(s => s.name); }"#.into(),
521 }))
522 .await;
523 match result {
524 Ok(json) => {
525 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
526 let names = parsed.as_array().unwrap();
527 assert_eq!(names[0], "test-server");
528 }
529 Err(e) => panic!("search should succeed: {e}"),
530 }
531 }
532
533 #[tokio::test]
534 async fn search_with_invalid_code_returns_error() {
535 let server = test_server();
536 let result = server
537 .search(Parameters(SearchInput {
538 code: r#"async () => { return eval("bad"); }"#.into(),
540 }))
541 .await;
542 assert!(result.is_ok(), "should return Ok with error JSON");
545 let json = result.unwrap();
546 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
547 assert!(
548 parsed["error"].as_str().unwrap().contains("banned pattern"),
549 "error should mention banned pattern: {parsed}"
550 );
551 }
552
553 #[tokio::test]
554 async fn execute_calls_tool() {
555 let server = test_server();
556 let result = server
557 .execute(Parameters(ExecuteInput {
558 code: r#"async () => {
559 return await forge.callTool("test-server", "tools.echo", { msg: "hi" });
560 }"#
561 .into(),
562 }))
563 .await;
564 match result {
565 Ok(json) => {
566 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
567 assert_eq!(parsed["server"], "test-server");
568 assert_eq!(parsed["tool"], "tools.echo");
569 assert_eq!(parsed["status"], "ok");
570 }
571 Err(e) => panic!("execute should succeed: {e}"),
572 }
573 }
574
575 #[tokio::test]
576 async fn execute_with_banned_code_returns_error() {
577 let server = test_server();
578 let result = server
579 .execute(Parameters(ExecuteInput {
580 code: r#"async () => { return eval("bad"); }"#.into(),
581 }))
582 .await;
583 assert!(result.is_ok(), "should return Ok with error JSON");
585 let json = result.unwrap();
586 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
587 assert!(
588 parsed["error"].as_str().unwrap().contains("banned pattern"),
589 "error should mention banned pattern: {parsed}"
590 );
591 }
592
593 #[tokio::test]
594 async fn empty_code_returns_error() {
595 let server = test_server();
596 let result = server
597 .search(Parameters(SearchInput { code: " ".into() }))
598 .await;
599 assert!(result.is_ok(), "should return Ok with error JSON");
601 let json = result.unwrap();
602 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
603 assert!(
604 parsed["error"].as_str().unwrap().contains("empty"),
605 "error should mention empty: {parsed}"
606 );
607 }
608
609 #[test]
612 fn truncate_result_short_passthrough() {
613 let short = r#"{"data": "hello"}"#.to_string();
614 let result = truncate_result_if_needed(short.clone());
615 assert_eq!(result, short, "short strings should pass through unchanged");
616 }
617
618 #[test]
619 fn truncate_result_long_truncates() {
620 let long = "x".repeat(MAX_RESULT_CHARS + 1000);
622 let result = truncate_result_if_needed(long.clone());
623
624 let parsed: serde_json::Value =
626 serde_json::from_str(&result).expect("truncated result should be valid JSON");
627 assert_eq!(parsed["_truncated"], true);
628 assert_eq!(parsed["_original_chars"], long.len());
629 let shown = parsed["_shown_chars"].as_u64().unwrap() as usize;
630 assert!(
631 shown < long.len(),
632 "shown chars should be less than original"
633 );
634 assert!(shown > 0, "should show some content");
635 let data = parsed["data"].as_str().unwrap();
636 assert_eq!(data.len(), shown, "data length should match _shown_chars");
637 }
638
639 #[test]
642 fn dts_01_instructions_contain_typescript_defs() {
643 let server = test_server();
644 let info = server.get_info();
645 let instructions = info.instructions.unwrap();
646 assert!(
647 instructions.contains("callTool"),
648 "instructions should contain callTool: {instructions}"
649 );
650 }
651
652 #[test]
653 fn dts_02_instructions_contain_forge_interface() {
654 let server = test_server();
655 let info = server.get_info();
656 let instructions = info.instructions.unwrap();
657 assert!(
658 instructions.contains("interface") || instructions.contains("Forge"),
659 "instructions should contain Forge interface"
660 );
661 }
662
663 #[test]
664 fn dts_03_instructions_contain_stash_types() {
665 let server = test_server();
666 let info = server.get_info();
667 let instructions = info.instructions.unwrap();
668 assert!(
669 instructions.contains("ForgeStash"),
670 "instructions should contain ForgeStash type"
671 );
672 }
673}