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