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;
14use std::time::Duration;
15
16use forge_manifest::{LiveManifest, Manifest};
17use forge_sandbox::groups::{
18    GroupEnforcingDispatcher, GroupEnforcingResourceDispatcher, GroupPolicy, SharedGroupLock,
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
31/// Maximum result size in characters before truncation.
32///
33/// Results exceeding this limit are wrapped in a JSON envelope with metadata
34/// about the truncation. This prevents oversized results from consuming the
35/// LLM's entire context window.
36const MAX_RESULT_CHARS: usize = 100_000;
37
38/// Truncate an oversized JSON result string, wrapping it with metadata.
39///
40/// Short results pass through unchanged. Results exceeding [`MAX_RESULT_CHARS`]
41/// are cut at a structure-aware boundary and wrapped in a JSON envelope with
42/// `_truncated`, `_data_is_fragment`, `_original_chars`, `_shown_chars`, and `data`.
43///
44/// The `data` field is a **string fragment**, not valid JSON — LLMs should not
45/// attempt to `JSON.parse()` it.
46fn truncate_result_if_needed(json: String) -> String {
47    if json.len() <= MAX_RESULT_CHARS {
48        return json;
49    }
50    let budget = MAX_RESULT_CHARS.saturating_sub(300); // reserve for envelope
51    let cut_point = find_safe_cut_point(&json, budget);
52
53    serde_json::json!({
54        "_truncated": true,
55        "_data_is_fragment": true,
56        "_original_chars": json.len(),
57        "_shown_chars": cut_point,
58        "data": &json[..cut_point]
59    })
60    .to_string()
61}
62
63/// Find the best cut point that minimizes JSON breakage.
64///
65/// For pretty-printed JSON (which we produce via `serde_json::to_string_pretty`),
66/// cutting at a newline boundary means we always end on a complete line.
67/// Falls back to the last comma, then to a character boundary.
68fn find_safe_cut_point(json: &str, max_pos: usize) -> usize {
69    let limit = floor_char_boundary(json, max_pos);
70    let search_region = &json[..limit];
71
72    // For pretty-printed JSON, cut at the last newline
73    if let Some(pos) = search_region.rfind('\n') {
74        if pos > limit / 2 {
75            return pos;
76        }
77    }
78
79    // Fallback: cut at the last comma (array/object separator)
80    if let Some(pos) = search_region.rfind(',') {
81        if pos > limit / 2 {
82            return pos + 1; // include the comma
83        }
84    }
85
86    // Final fallback: last valid character boundary
87    search_region
88        .char_indices()
89        .last()
90        .map(|(i, c)| i + c.len_utf8())
91        .unwrap_or(0)
92}
93
94fn floor_char_boundary(s: &str, max: usize) -> usize {
95    let mut end = max.min(s.len());
96    while end > 0 && !s.is_char_boundary(end) {
97        end -= 1;
98    }
99    end
100}
101
102/// Format a sandbox execution result for the LLM.
103///
104/// Shared between `search()` and `execute()` to avoid duplicated error handling.
105fn format_sandbox_result(
106    result: Result<serde_json::Value, impl std::fmt::Display>,
107) -> Result<String, String> {
108    match result {
109        Ok(value) => {
110            let json = serde_json::to_string_pretty(&value)
111                .map_err(|e| format!("result serialization failed: {e}"))?;
112            Ok(truncate_result_if_needed(json))
113        }
114        Err(e) => {
115            let msg = format!("{e}");
116            let clean = msg.strip_prefix("javascript error: ").unwrap_or(&msg);
117            Ok(serde_json::json!({"error": clean}).to_string())
118        }
119    }
120}
121
122/// The Forge MCP server handler.
123///
124/// Implements `ServerHandler` from rmcp to serve the `search` and `execute`
125/// Code Mode tools over MCP stdio or SSE transport.
126#[derive(Clone)]
127pub struct ForgeServer {
128    executor: Arc<SandboxExecutor>,
129    manifest: LiveManifest,
130    dispatcher: Arc<dyn ToolDispatcher>,
131    resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
132    group_policy: Option<Arc<GroupPolicy>>,
133    session_stash: Option<Arc<tokio::sync::Mutex<SessionStash>>>,
134    tool_router: ToolRouter<Self>,
135}
136
137struct ExecutionDispatchers {
138    dispatcher: Arc<dyn ToolDispatcher>,
139    resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
140    group_lock: Option<SharedGroupLock>,
141}
142
143/// Stash dispatcher that wraps a shared [`SessionStash`] behind a Mutex.
144///
145/// Created per-execution by `ForgeServer::execute()` to provide the stash API
146/// to sandbox code. The `current_group` is set from the server group context.
147struct ServerStashDispatcher {
148    stash: Arc<tokio::sync::Mutex<SessionStash>>,
149    group_lock: Option<SharedGroupLock>,
150}
151
152impl ServerStashDispatcher {
153    async fn current_group(&self, op_group: Option<String>) -> Option<String> {
154        if let Some(lock) = &self.group_lock {
155            return lock.lock().await.clone();
156        }
157        op_group
158    }
159}
160
161#[async_trait::async_trait]
162impl StashDispatcher for ServerStashDispatcher {
163    async fn put(
164        &self,
165        key: &str,
166        value: serde_json::Value,
167        ttl_secs: Option<u32>,
168        current_group: Option<String>,
169    ) -> Result<serde_json::Value, forge_error::DispatchError> {
170        let ttl = ttl_secs
171            .filter(|&s| s > 0)
172            .map(|s| Duration::from_secs(s as u64));
173        let current_group = self.current_group(current_group).await;
174        let mut stash = self.stash.lock().await;
175        stash
176            .put(key, value, ttl, current_group.as_deref())
177            .map_err(|e| forge_error::DispatchError::Internal(e.into()))?;
178        Ok(serde_json::json!({"ok": true}))
179    }
180
181    async fn get(
182        &self,
183        key: &str,
184        current_group: Option<String>,
185    ) -> Result<serde_json::Value, forge_error::DispatchError> {
186        let current_group = self.current_group(current_group).await;
187        let stash = self.stash.lock().await;
188        match stash
189            .get(key, current_group.as_deref())
190            .map_err(|e| forge_error::DispatchError::Internal(e.into()))?
191        {
192            Some(v) => Ok(v.clone()),
193            None => Ok(serde_json::Value::Null),
194        }
195    }
196
197    async fn delete(
198        &self,
199        key: &str,
200        current_group: Option<String>,
201    ) -> Result<serde_json::Value, forge_error::DispatchError> {
202        let current_group = self.current_group(current_group).await;
203        let mut stash = self.stash.lock().await;
204        let deleted = stash
205            .delete(key, current_group.as_deref())
206            .map_err(|e| forge_error::DispatchError::Internal(e.into()))?;
207        Ok(serde_json::json!({"deleted": deleted}))
208    }
209
210    async fn keys(
211        &self,
212        current_group: Option<String>,
213    ) -> Result<serde_json::Value, forge_error::DispatchError> {
214        let current_group = self.current_group(current_group).await;
215        let stash = self.stash.lock().await;
216        let keys: Vec<&str> = stash.keys(current_group.as_deref());
217        Ok(serde_json::json!(keys))
218    }
219}
220
221impl ForgeServer {
222    /// Create a new Forge server with the given config, manifest, dispatcher,
223    /// and optional resource dispatcher.
224    pub fn new(
225        config: SandboxConfig,
226        manifest: Manifest,
227        dispatcher: Arc<dyn ToolDispatcher>,
228        resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
229    ) -> Self {
230        Self {
231            executor: Arc::new(SandboxExecutor::new(config)),
232            manifest: LiveManifest::new(manifest),
233            dispatcher,
234            resource_dispatcher,
235            group_policy: None,
236            session_stash: None,
237            tool_router: Self::tool_router(),
238        }
239    }
240
241    /// Set a group policy for cross-server data flow enforcement.
242    ///
243    /// When set, each `execute()` call wraps the dispatcher with a fresh
244    /// [`GroupEnforcingDispatcher`] that tracks group access for that execution.
245    /// If a resource dispatcher is also configured, it is wrapped with a
246    /// [`GroupEnforcingResourceDispatcher`] sharing the same lock.
247    pub fn with_group_policy(mut self, policy: GroupPolicy) -> Self {
248        if !policy.is_empty() {
249            self.group_policy = Some(Arc::new(policy));
250        }
251        self
252    }
253
254    /// Enable the session stash with the given configuration.
255    ///
256    /// When enabled, `forge.stash.put/get/delete/keys()` are available in
257    /// sandbox execute mode.
258    pub fn with_stash(mut self, config: StashConfig) -> Self {
259        self.session_stash = Some(Arc::new(tokio::sync::Mutex::new(SessionStash::new(config))));
260        self
261    }
262
263    /// Create a new Forge server with a pre-configured executor.
264    ///
265    /// Use this when you need to attach a worker pool to the executor
266    /// before wrapping it in the server.
267    pub fn new_with_executor(
268        executor: SandboxExecutor,
269        manifest: Manifest,
270        dispatcher: Arc<dyn ToolDispatcher>,
271        resource_dispatcher: Option<Arc<dyn ResourceDispatcher>>,
272    ) -> Self {
273        Self {
274            executor: Arc::new(executor),
275            manifest: LiveManifest::new(manifest),
276            dispatcher,
277            resource_dispatcher,
278            group_policy: None,
279            session_stash: None,
280            tool_router: Self::tool_router(),
281        }
282    }
283
284    /// Get a reference to the live manifest for external updates.
285    ///
286    /// Background tasks can call [`LiveManifest::update()`] to refresh
287    /// the manifest without restarting the server.
288    pub fn live_manifest(&self) -> &LiveManifest {
289        &self.manifest
290    }
291}
292
293/// Input for the `search` tool.
294#[derive(Debug, Deserialize, JsonSchema)]
295pub struct SearchInput {
296    /// JavaScript async arrow function to search the capability manifest.
297    /// The manifest is available as `globalThis.manifest` with servers,
298    /// categories, and tool schemas.
299    ///
300    /// IMPORTANT: `server.categories` is an Object keyed by name (NOT an array).
301    /// Use `Object.entries(s.categories)` or `Object.values(s.categories)` to iterate.
302    /// Each category has a `.tools` Array with `.name`, `.description`, `.input_schema`.
303    /// Check `input_schema.required` before calling a tool to get the right parameters.
304    pub code: String,
305}
306
307/// Input for the `execute` tool.
308#[derive(Debug, Deserialize, JsonSchema)]
309pub struct ExecuteInput {
310    /// JavaScript async arrow function to execute against the tool API.
311    /// Use `forge.callTool(server, tool, args)` or
312    /// `forge.server("name").category.tool(args)` to call tools.
313    ///
314    /// Runs in a sandboxed V8 isolate — no filesystem, network, or module access.
315    /// `import()`, `require()`, `eval()`, and `Deno.*` are all blocked.
316    pub code: String,
317}
318
319#[tool_router(router = tool_router)]
320impl ForgeServer {
321    /// Search the capability manifest to discover available tools across all
322    /// connected servers. The manifest is available as `globalThis.manifest`.
323    #[tool(
324        name = "search",
325        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) })); }`"
326    )]
327    #[tracing::instrument(skip(self, input), fields(code_len = input.code.len()))]
328    pub async fn search(
329        &self,
330        Parameters(input): Parameters<SearchInput>,
331    ) -> Result<String, String> {
332        tracing::info!("search: starting");
333
334        // Snapshot the manifest for this search — lock-free read
335        let manifest = self.manifest.current();
336        let manifest_json = manifest
337            .to_json()
338            .map_err(|e| format!("manifest serialization failed: {e}"))?;
339
340        let result = self
341            .executor
342            .execute_search(&input.code, &manifest_json)
343            .await;
344
345        if result.is_ok() {
346            tracing::info!("search: complete");
347        } else {
348            tracing::warn!("search: failed");
349        }
350
351        format_sandbox_result(result)
352    }
353
354    /// Execute code against the tool API in a sandboxed V8 isolate.
355    #[tool(
356        name = "execute",
357        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."
358    )]
359    #[tracing::instrument(skip(self, input), fields(code_len = input.code.len()))]
360    pub async fn execute(
361        &self,
362        Parameters(input): Parameters<ExecuteInput>,
363    ) -> Result<String, String> {
364        tracing::info!("execute: starting");
365
366        // Wrap dispatcher(s) with group enforcement if a policy is configured.
367        // A fresh pair of GroupEnforcingDispatcher/GroupEnforcingResourceDispatcher
368        // is created per-execution so that group locking state doesn't leak
369        // between executions. Both share the same lock for consistent enforcement.
370        let dispatchers = match &self.group_policy {
371            Some(policy) => {
372                let tool_enforcer =
373                    GroupEnforcingDispatcher::new(self.dispatcher.clone(), policy.clone());
374                let shared_lock = tool_enforcer.shared_lock();
375
376                let resource = self.resource_dispatcher.as_ref().map(|rd| {
377                    Arc::new(GroupEnforcingResourceDispatcher::new(
378                        rd.clone(),
379                        policy.clone(),
380                        shared_lock.clone(),
381                    )) as Arc<dyn ResourceDispatcher>
382                });
383
384                ExecutionDispatchers {
385                    dispatcher: Arc::new(tool_enforcer),
386                    resource_dispatcher: resource,
387                    group_lock: Some(shared_lock),
388                }
389            }
390            None => ExecutionDispatchers {
391                dispatcher: self.dispatcher.clone(),
392                resource_dispatcher: self.resource_dispatcher.clone(),
393                group_lock: None,
394            },
395        };
396
397        // Create stash dispatcher if session stash is configured
398        let stash_dispatcher: Option<Arc<dyn StashDispatcher>> =
399            self.session_stash.as_ref().map(|stash| {
400                Arc::new(ServerStashDispatcher {
401                    stash: stash.clone(),
402                    group_lock: dispatchers.group_lock.clone(),
403                }) as Arc<dyn StashDispatcher>
404            });
405
406        // Snapshot the manifest for this execution — lock-free read
407        let manifest = self.manifest.current();
408
409        // SR-R6: Collect known server names from manifest for op-level validation
410        let known_servers: std::collections::HashSet<String> =
411            manifest.servers.iter().map(|s| s.name.clone()).collect();
412
413        // Collect known (server, tool) pairs for structured error fuzzy matching
414        let known_tools: Vec<(String, String)> = manifest
415            .servers
416            .iter()
417            .flat_map(|s| {
418                s.categories
419                    .values()
420                    .flat_map(|cat| cat.tools.iter().map(|t| (s.name.clone(), t.name.clone())))
421            })
422            .collect();
423
424        let result = self
425            .executor
426            .execute_code_with_options(
427                &input.code,
428                dispatchers.dispatcher,
429                dispatchers.resource_dispatcher,
430                stash_dispatcher,
431                Some(known_servers),
432                Some(known_tools),
433            )
434            .await;
435
436        if result.is_ok() {
437            tracing::info!("execute: complete");
438        } else {
439            tracing::warn!("execute: failed");
440        }
441
442        format_sandbox_result(result)
443    }
444}
445
446#[tool_handler(router = self.tool_router)]
447impl ServerHandler for ForgeServer {
448    fn get_info(&self) -> ServerInfo {
449        let manifest = self.manifest.current();
450        let stats = format!(
451            "{} servers, {} tools",
452            manifest.total_servers(),
453            manifest.total_tools(),
454        );
455
456        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
457            .with_instructions(format!(
458                "Forgemax Code Mode Gateway ({stats}). \
459                 Use search() to discover available tools, then execute() to call them.\n\
460                 \n\
461                 Both tools take a `code` parameter containing a JavaScript async arrow function.\n\
462                 Example: `async () => {{ return manifest.servers.map(s => s.name); }}`\n\
463                 \n\
464                 Manifest shape:\n\
465                 - manifest.servers: Array of {{ name, description, categories }}\n\
466                 - server.categories: Object (NOT array) keyed by category name, e.g. categories[\"ast\"]\n\
467                 - Use Object.entries(s.categories) or Object.values(s.categories) to iterate categories\n\
468                 - Each category has .tools (Array) with .name, .description, .input_schema\n\
469                 - Always check a tool's input_schema.required before calling it\n\
470                 \n\
471                 Sandboxed environment — no filesystem, network, or module imports (import/require/eval are blocked). \
472                 Use forge.callTool(server, tool, args) for all external operations.\n\
473                 \n\
474                 When calling tools, use the tool name only (e.g. \"find_symbols\"), \
475                 not the category-prefixed form (e.g. NOT \"general.find_symbols\").\n\
476                 \n\
477                 Additional APIs (execute mode only):\n\
478                 - forge.readResource(server, uri) — read MCP resources from downstream servers\n\
479                 - forge.stash.put(key, value, {{ttl?}}) / .get(key) / .delete(key) / .keys() — \
480                 session-scoped key-value store for sharing data across executions\n\
481                 - forge.parallel(calls, opts) — bounded concurrent execution of tool/resource calls\n\
482                 \n\
483                 ## TypeScript API Definitions\n\
484                 \n\
485                 ```typescript\n\
486                 {dts}\n\
487                 ```",
488                dts = forge_manifest::FORGE_DTS
489            ))
490            .with_server_info(Implementation::new("forge", env!("CARGO_PKG_VERSION")))
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use forge_manifest::{Category, ManifestBuilder, ServerBuilder, ToolEntry};
498
499    struct TestDispatcher;
500
501    #[async_trait::async_trait]
502    impl ToolDispatcher for TestDispatcher {
503        async fn call_tool(
504            &self,
505            server: &str,
506            tool: &str,
507            args: serde_json::Value,
508        ) -> Result<serde_json::Value, forge_error::DispatchError> {
509            Ok(serde_json::json!({
510                "server": server,
511                "tool": tool,
512                "args": args,
513                "status": "ok"
514            }))
515        }
516    }
517
518    fn test_server() -> ForgeServer {
519        let manifest = ManifestBuilder::new()
520            .add_server(
521                ServerBuilder::new("test-server", "A test server")
522                    .add_category(Category {
523                        name: "tools".into(),
524                        description: "Test tools".into(),
525                        tools: vec![ToolEntry {
526                            name: "echo".into(),
527                            description: "Echoes input".into(),
528                            params: vec![],
529                            returns: Some("The input".into()),
530                            input_schema: None,
531                        }],
532                    })
533                    .build(),
534            )
535            .build();
536        let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
537        ForgeServer::new(SandboxConfig::default(), manifest, dispatcher, None)
538    }
539
540    #[tokio::test]
541    async fn stash_dispatcher_uses_group_lock_for_writes_and_reads() {
542        let stash = Arc::new(tokio::sync::Mutex::new(SessionStash::new(
543            StashConfig::default(),
544        )));
545        let internal_lock: SharedGroupLock =
546            Arc::new(tokio::sync::Mutex::new(Some("internal".to_string())));
547        let external_lock: SharedGroupLock =
548            Arc::new(tokio::sync::Mutex::new(Some("external".to_string())));
549
550        let internal = ServerStashDispatcher {
551            stash: stash.clone(),
552            group_lock: Some(internal_lock),
553        };
554        internal
555            .put("secret", serde_json::json!({"token": "red"}), None, None)
556            .await
557            .unwrap();
558
559        let same_group = internal.get("secret", None).await.unwrap();
560        assert_eq!(same_group["token"], "red");
561
562        let external = ServerStashDispatcher {
563            stash,
564            group_lock: Some(external_lock),
565        };
566        let err = external.get("secret", None).await.unwrap_err();
567        assert!(
568            err.to_string().contains("cross-group"),
569            "expected cross-group denial, got: {err}"
570        );
571    }
572
573    #[test]
574    fn get_info_returns_correct_metadata() {
575        let server = test_server();
576        let info = server.get_info();
577        assert_eq!(info.server_info.name, "forge");
578        assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
579        let instructions = info.instructions.unwrap();
580        assert!(instructions.contains("search()"));
581        assert!(instructions.contains("execute()"));
582        assert!(instructions.contains("1 servers, 1 tools"));
583        // Verify improved documentation is present
584        assert!(
585            instructions.contains("async arrow function"),
586            "instructions should mention async arrow function format"
587        );
588        assert!(
589            instructions.contains("Object (NOT array)"),
590            "instructions should warn about categories being an Object"
591        );
592        assert!(
593            instructions.contains("input_schema"),
594            "instructions should mention input_schema for parameter discovery"
595        );
596        assert!(
597            instructions.contains("no filesystem"),
598            "instructions should mention sandbox constraints"
599        );
600        assert!(
601            instructions.contains("use the tool name only"),
602            "instructions should clarify tool name vs category-prefixed form"
603        );
604    }
605
606    #[tokio::test]
607    async fn search_returns_json() {
608        let server = test_server();
609        let result = server
610            .search(Parameters(SearchInput {
611                code: r#"async () => { return manifest.servers.map(s => s.name); }"#.into(),
612            }))
613            .await;
614        match result {
615            Ok(json) => {
616                let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
617                let names = parsed.as_array().unwrap();
618                assert_eq!(names[0], "test-server");
619            }
620            Err(e) => panic!("search should succeed: {e}"),
621        }
622    }
623
624    #[tokio::test]
625    async fn search_with_invalid_code_returns_error() {
626        let server = test_server();
627        let result = server
628            .search(Parameters(SearchInput {
629                // eval( is a banned pattern
630                code: r#"async () => { return eval("bad"); }"#.into(),
631            }))
632            .await;
633        // WI-1: Errors return Ok with JSON error field (not Err) to prevent
634        // sibling tool call cascade failures.
635        assert!(result.is_ok(), "should return Ok with error JSON");
636        let json = result.unwrap();
637        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
638        assert!(
639            parsed["error"].as_str().unwrap().contains("banned pattern"),
640            "error should mention banned pattern: {parsed}"
641        );
642    }
643
644    #[tokio::test]
645    async fn execute_calls_tool() {
646        let server = test_server();
647        let result = server
648            .execute(Parameters(ExecuteInput {
649                code: r#"async () => {
650                    return await forge.callTool("test-server", "tools.echo", { msg: "hi" });
651                }"#
652                .into(),
653            }))
654            .await;
655        match result {
656            Ok(json) => {
657                let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
658                assert_eq!(parsed["server"], "test-server");
659                assert_eq!(parsed["tool"], "tools.echo");
660                assert_eq!(parsed["status"], "ok");
661            }
662            Err(e) => panic!("execute should succeed: {e}"),
663        }
664    }
665
666    #[tokio::test]
667    async fn execute_with_banned_code_returns_error() {
668        let server = test_server();
669        let result = server
670            .execute(Parameters(ExecuteInput {
671                code: r#"async () => { return eval("bad"); }"#.into(),
672            }))
673            .await;
674        // WI-1: Errors return Ok with JSON error field (not Err)
675        assert!(result.is_ok(), "should return Ok with error JSON");
676        let json = result.unwrap();
677        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
678        assert!(
679            parsed["error"].as_str().unwrap().contains("banned pattern"),
680            "error should mention banned pattern: {parsed}"
681        );
682    }
683
684    #[tokio::test]
685    async fn empty_code_returns_error() {
686        let server = test_server();
687        let result = server
688            .search(Parameters(SearchInput { code: "   ".into() }))
689            .await;
690        // WI-1: Errors return Ok with JSON error field (not Err)
691        assert!(result.is_ok(), "should return Ok with error JSON");
692        let json = result.unwrap();
693        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
694        assert!(
695            parsed["error"].as_str().unwrap().contains("empty"),
696            "error should mention empty: {parsed}"
697        );
698    }
699
700    // --- WI-2: Output truncation tests ---
701
702    #[test]
703    fn truncate_result_short_passthrough() {
704        let short = r#"{"data": "hello"}"#.to_string();
705        let result = truncate_result_if_needed(short.clone());
706        assert_eq!(result, short, "short strings should pass through unchanged");
707    }
708
709    #[test]
710    fn truncate_result_long_truncates() {
711        // Create a string longer than MAX_RESULT_CHARS
712        let long = "x".repeat(MAX_RESULT_CHARS + 1000);
713        let result = truncate_result_if_needed(long.clone());
714
715        // Should be valid JSON with truncation metadata
716        let parsed: serde_json::Value =
717            serde_json::from_str(&result).expect("truncated result should be valid JSON");
718        assert_eq!(parsed["_truncated"], true);
719        assert_eq!(parsed["_original_chars"], long.len());
720        let shown = parsed["_shown_chars"].as_u64().unwrap() as usize;
721        assert!(
722            shown < long.len(),
723            "shown chars should be less than original"
724        );
725        assert!(shown > 0, "should show some content");
726        let data = parsed["data"].as_str().unwrap();
727        assert_eq!(data.len(), shown, "data length should match _shown_chars");
728    }
729
730    #[test]
731    fn tr_02_truncate_cuts_at_newline() {
732        // Pretty-printed JSON should be cut at a newline boundary
733        let mut obj = serde_json::Map::new();
734        for i in 0..5000 {
735            obj.insert(format!("key_{i}"), serde_json::json!(format!("value_{i}")));
736        }
737        let pretty = serde_json::to_string_pretty(&obj).unwrap();
738        assert!(
739            pretty.len() > MAX_RESULT_CHARS,
740            "test fixture should exceed limit"
741        );
742
743        let result = truncate_result_if_needed(pretty);
744        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
745        let data = parsed["data"].as_str().unwrap();
746        // Should end at a newline (last char of the fragment)
747        assert!(
748            data.ends_with('\n') || data.ends_with(','),
749            "should cut at newline or comma boundary, but ends with: {:?}",
750            data.chars().last()
751        );
752    }
753
754    #[test]
755    fn tr_03_truncate_envelope_is_valid_json() {
756        let long = "x".repeat(MAX_RESULT_CHARS + 500);
757        let result = truncate_result_if_needed(long);
758        let parsed: serde_json::Value =
759            serde_json::from_str(&result).expect("envelope should be valid JSON");
760        assert!(parsed.is_object(), "envelope should be a JSON object");
761        assert!(parsed.get("_truncated").is_some());
762        assert!(parsed.get("data").is_some());
763    }
764
765    #[test]
766    fn tr_04_truncate_data_fragment_flag() {
767        let long = "y".repeat(MAX_RESULT_CHARS + 100);
768        let result = truncate_result_if_needed(long);
769        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
770        assert_eq!(
771            parsed["_data_is_fragment"], true,
772            "truncated results should carry _data_is_fragment flag"
773        );
774    }
775
776    #[test]
777    fn tr_05_truncate_minified_json_fallback() {
778        // Minified JSON (no newlines) should fall back to comma boundary
779        let items: Vec<String> = (0..20000).map(|i| format!("\"item_{i}\"")).collect();
780        let minified = format!("[{}]", items.join(","));
781        assert!(minified.len() > MAX_RESULT_CHARS);
782
783        let result = truncate_result_if_needed(minified);
784        let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
785        assert_eq!(parsed["_truncated"], true);
786        let data = parsed["data"].as_str().unwrap();
787        // Should end at a comma boundary (includes the comma)
788        let trimmed = data.trim_end();
789        assert!(
790            trimmed.ends_with(',') || trimmed.ends_with('"'),
791            "minified JSON should cut at comma: ends with {:?}",
792            trimmed.chars().last()
793        );
794    }
795
796    #[test]
797    fn tr_06_truncate_unicode_safe() {
798        // Multi-byte UTF-8 should not be split mid-character
799        let emoji = "\u{1F600}"; // 4-byte emoji
800        let mut long = String::new();
801        while long.len() < MAX_RESULT_CHARS + 500 {
802            long.push_str(emoji);
803        }
804        let result = truncate_result_if_needed(long);
805        // Should not panic, and envelope should be valid JSON
806        let parsed: serde_json::Value =
807            serde_json::from_str(&result).expect("unicode truncation should produce valid JSON");
808        assert_eq!(parsed["_truncated"], true);
809    }
810
811    #[test]
812    fn tr_06b_truncate_three_byte_unicode_safe() {
813        let cjk = "漢";
814        let mut long = String::new();
815        while long.len() < MAX_RESULT_CHARS + 500 {
816            long.push_str(cjk);
817        }
818
819        let result = truncate_result_if_needed(long);
820        let parsed: serde_json::Value =
821            serde_json::from_str(&result).expect("unicode truncation should produce valid JSON");
822        assert_eq!(parsed["_truncated"], true);
823        assert!(parsed["data"].as_str().unwrap().chars().all(|c| c == '漢'));
824    }
825
826    #[test]
827    fn tr_07_format_sandbox_result_ok() {
828        let value = serde_json::json!({"result": "hello"});
829        let result = format_sandbox_result(Ok::<_, String>(value));
830        assert!(result.is_ok());
831        let json = result.unwrap();
832        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
833        assert_eq!(parsed["result"], "hello");
834    }
835
836    #[test]
837    fn tr_08_format_sandbox_result_err_strips_prefix() {
838        let err = "javascript error: some problem";
839        let result = format_sandbox_result(Err::<serde_json::Value, _>(err));
840        assert!(result.is_ok());
841        let json = result.unwrap();
842        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
843        assert_eq!(parsed["error"], "some problem");
844    }
845
846    // --- Phase R3: FORGE_DTS in instructions ---
847
848    #[test]
849    fn dts_01_instructions_contain_typescript_defs() {
850        let server = test_server();
851        let info = server.get_info();
852        let instructions = info.instructions.unwrap();
853        assert!(
854            instructions.contains("callTool"),
855            "instructions should contain callTool: {instructions}"
856        );
857    }
858
859    #[test]
860    fn dts_02_instructions_contain_forge_interface() {
861        let server = test_server();
862        let info = server.get_info();
863        let instructions = info.instructions.unwrap();
864        assert!(
865            instructions.contains("interface") || instructions.contains("Forge"),
866            "instructions should contain Forge interface"
867        );
868    }
869
870    #[test]
871    fn dts_03_instructions_contain_stash_types() {
872        let server = test_server();
873        let info = server.get_info();
874        let instructions = info.instructions.unwrap();
875        assert!(
876            instructions.contains("ForgeStash"),
877            "instructions should contain ForgeStash type"
878        );
879    }
880}