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 the last valid character boundary before the limit and wrapped in
42/// a JSON object with `_truncated`, `_original_chars`, `_shown_chars`, and `data`.
43fn 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/// The Forge MCP server handler.
63///
64/// Implements `ServerHandler` from rmcp to serve the `search` and `execute`
65/// Code Mode tools over MCP stdio or SSE transport.
66#[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
77/// Stash dispatcher that wraps a shared [`SessionStash`] behind a Mutex.
78///
79/// Created per-execution by `ForgeServer::execute()` to provide the stash API
80/// to sandbox code. The `current_group` is set from the server group context.
81struct 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    /// Create a new Forge server with the given config, manifest, dispatcher,
144    /// and optional resource dispatcher.
145    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    /// Set a group policy for cross-server data flow enforcement.
163    ///
164    /// When set, each `execute()` call wraps the dispatcher with a fresh
165    /// [`GroupEnforcingDispatcher`] that tracks group access for that execution.
166    /// If a resource dispatcher is also configured, it is wrapped with a
167    /// [`GroupEnforcingResourceDispatcher`] sharing the same lock.
168    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    /// Enable the session stash with the given configuration.
176    ///
177    /// When enabled, `forge.stash.put/get/delete/keys()` are available in
178    /// sandbox execute mode.
179    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    /// Create a new Forge server with a pre-configured executor.
185    ///
186    /// Use this when you need to attach a worker pool to the executor
187    /// before wrapping it in the server.
188    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    /// Get a reference to the live manifest for external updates.
206    ///
207    /// Background tasks can call [`LiveManifest::update()`] to refresh
208    /// the manifest without restarting the server.
209    pub fn live_manifest(&self) -> &LiveManifest {
210        &self.manifest
211    }
212}
213
214/// Input for the `search` tool.
215#[derive(Debug, Deserialize, JsonSchema)]
216pub struct SearchInput {
217    /// JavaScript async arrow function to search the capability manifest.
218    /// The manifest is available as `globalThis.manifest` with servers,
219    /// categories, and tool schemas.
220    ///
221    /// IMPORTANT: `server.categories` is an Object keyed by name (NOT an array).
222    /// Use `Object.entries(s.categories)` or `Object.values(s.categories)` to iterate.
223    /// Each category has a `.tools` Array with `.name`, `.description`, `.input_schema`.
224    /// Check `input_schema.required` before calling a tool to get the right parameters.
225    pub code: String,
226}
227
228/// Input for the `execute` tool.
229#[derive(Debug, Deserialize, JsonSchema)]
230pub struct ExecuteInput {
231    /// JavaScript async arrow function to execute against the tool API.
232    /// Use `forge.callTool(server, tool, args)` or
233    /// `forge.server("name").category.tool(args)` to call tools.
234    ///
235    /// Runs in a sandboxed V8 isolate — no filesystem, network, or module access.
236    /// `import()`, `require()`, `eval()`, and `Deno.*` are all blocked.
237    pub code: String,
238}
239
240#[tool_router(router = tool_router)]
241impl ForgeServer {
242    /// Search the capability manifest to discover available tools across all
243    /// connected servers. The manifest is available as `globalThis.manifest`.
244    #[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        // Snapshot the manifest for this search — lock-free read
256        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                // JsError messages are already redacted at the op level; strip
276                // the internal "javascript error:" prefix for cleaner LLM output.
277                let clean = msg.strip_prefix("javascript error: ").unwrap_or(&msg);
278                Ok(serde_json::json!({"error": clean}).to_string())
279            }
280        }
281    }
282
283    /// Execute code against the tool API in a sandboxed V8 isolate.
284    #[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        // Wrap dispatcher(s) with group enforcement if a policy is configured.
296        // A fresh pair of GroupEnforcingDispatcher/GroupEnforcingResourceDispatcher
297        // is created per-execution so that group locking state doesn't leak
298        // between executions. Both share the same lock for consistent enforcement.
299        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        // Create stash dispatcher if session stash is configured
322        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, // Group tracking done at ForgeServer level
327                }) as Arc<dyn StashDispatcher>
328            });
329
330        // Snapshot the manifest for this execution — lock-free read
331        let manifest = self.manifest.current();
332
333        // SR-R6: Collect known server names from manifest for op-level validation
334        let known_servers: std::collections::HashSet<String> =
335            manifest.servers.iter().map(|s| s.name.clone()).collect();
336
337        // Collect known (server, tool) pairs for structured error fuzzy matching
338        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                // JsError messages are already redacted at the op level; strip
370                // the internal "javascript error:" prefix for cleaner LLM output.
371                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        // Verify improved documentation is present
493        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                // eval( is a banned pattern
539                code: r#"async () => { return eval("bad"); }"#.into(),
540            }))
541            .await;
542        // WI-1: Errors return Ok with JSON error field (not Err) to prevent
543        // sibling tool call cascade failures.
544        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        // WI-1: Errors return Ok with JSON error field (not Err)
584        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        // WI-1: Errors return Ok with JSON error field (not Err)
600        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    // --- WI-2: Output truncation tests ---
610
611    #[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        // Create a string longer than MAX_RESULT_CHARS
621        let long = "x".repeat(MAX_RESULT_CHARS + 1000);
622        let result = truncate_result_if_needed(long.clone());
623
624        // Should be valid JSON with truncation metadata
625        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    // --- Phase R3: FORGE_DTS in instructions ---
640
641    #[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}