1#![warn(missing_docs)]
2
3use std::sync::Arc;
14use std::time::Duration;
15
16use forge_manifest::{LiveManifest, Manifest};
17use forge_sandbox::groups::{
18 GroupEnforcingDispatcher, GroupEnforcingResourceDispatcher, GroupPolicy, 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
31const MAX_RESULT_CHARS: usize = 100_000;
37
38fn 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); 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
63fn 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 if let Some(pos) = search_region.rfind('\n') {
74 if pos > limit / 2 {
75 return pos;
76 }
77 }
78
79 if let Some(pos) = search_region.rfind(',') {
81 if pos > limit / 2 {
82 return pos + 1; }
84 }
85
86 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
102fn 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#[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
143struct 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 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 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 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 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 pub fn live_manifest(&self) -> &LiveManifest {
289 &self.manifest
290 }
291}
292
293#[derive(Debug, Deserialize, JsonSchema)]
295pub struct SearchInput {
296 pub code: String,
305}
306
307#[derive(Debug, Deserialize, JsonSchema)]
309pub struct ExecuteInput {
310 pub code: String,
317}
318
319#[tool_router(router = tool_router)]
320impl ForgeServer {
321 #[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 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 #[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 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 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 let manifest = self.manifest.current();
408
409 let known_servers: std::collections::HashSet<String> =
411 manifest.servers.iter().map(|s| s.name.clone()).collect();
412
413 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 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 code: r#"async () => { return eval("bad"); }"#.into(),
631 }))
632 .await;
633 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 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 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 #[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 let long = "x".repeat(MAX_RESULT_CHARS + 1000);
713 let result = truncate_result_if_needed(long.clone());
714
715 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 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 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 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 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 let emoji = "\u{1F600}"; 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 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 #[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}