1#![warn(missing_docs)]
2
3use std::sync::Arc;
14use std::time::Duration;
15
16use forge_manifest::{LiveManifest, Manifest};
17use forge_sandbox::groups::{
18 GroupEnforcingDispatcher, GroupEnforcingResourceDispatcher, GroupPolicy,
19};
20use forge_sandbox::stash::{SessionStash, StashConfig};
21use forge_sandbox::{
22 ResourceDispatcher, SandboxConfig, SandboxExecutor, StashDispatcher, ToolDispatcher,
23};
24use rmcp::handler::server::router::tool::ToolRouter;
25use rmcp::handler::server::wrapper::Parameters;
26use rmcp::model::{Implementation, ServerCapabilities, ServerInfo};
27use rmcp::schemars::JsonSchema;
28use rmcp::{tool, tool_handler, tool_router, ServerHandler};
29use serde::Deserialize;
30
31const MAX_RESULT_CHARS: usize = 100_000;
37
38fn truncate_result_if_needed(json: String) -> String {
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 = max_pos.min(json.len());
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 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#[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
129struct 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 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 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 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 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 pub fn live_manifest(&self) -> &LiveManifest {
262 &self.manifest
263 }
264}
265
266#[derive(Debug, Deserialize, JsonSchema)]
268pub struct SearchInput {
269 pub code: String,
278}
279
280#[derive(Debug, Deserialize, JsonSchema)]
282pub struct ExecuteInput {
283 pub code: String,
290}
291
292#[tool_router(router = tool_router)]
293impl ForgeServer {
294 #[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 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 #[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 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 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, }) as Arc<dyn StashDispatcher>
372 });
373
374 let manifest = self.manifest.current();
376
377 let known_servers: std::collections::HashSet<String> =
379 manifest.servers.iter().map(|s| s.name.clone()).collect();
380
381 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 {
425 capabilities: ServerCapabilities::builder().enable_tools().build(),
426 instructions: Some(format!(
427 "Forgemax Code Mode Gateway ({stats}). \
428 Use search() to discover available tools, then execute() to call them.\n\
429 \n\
430 Both tools take a `code` parameter containing a JavaScript async arrow function.\n\
431 Example: `async () => {{ return manifest.servers.map(s => s.name); }}`\n\
432 \n\
433 Manifest shape:\n\
434 - manifest.servers: Array of {{ name, description, categories }}\n\
435 - server.categories: Object (NOT array) keyed by category name, e.g. categories[\"ast\"]\n\
436 - Use Object.entries(s.categories) or Object.values(s.categories) to iterate categories\n\
437 - Each category has .tools (Array) with .name, .description, .input_schema\n\
438 - Always check a tool's input_schema.required before calling it\n\
439 \n\
440 Sandboxed environment — no filesystem, network, or module imports (import/require/eval are blocked). \
441 Use forge.callTool(server, tool, args) for all external operations.\n\
442 \n\
443 When calling tools, use the tool name only (e.g. \"find_symbols\"), \
444 not the category-prefixed form (e.g. NOT \"general.find_symbols\").\n\
445 \n\
446 Additional APIs (execute mode only):\n\
447 - forge.readResource(server, uri) — read MCP resources from downstream servers\n\
448 - forge.stash.put(key, value, {{ttl?}}) / .get(key) / .delete(key) / .keys() — \
449 session-scoped key-value store for sharing data across executions\n\
450 - forge.parallel(calls, opts) — bounded concurrent execution of tool/resource calls\n\
451 \n\
452 ## TypeScript API Definitions\n\
453 \n\
454 ```typescript\n\
455 {dts}\n\
456 ```",
457 dts = forge_manifest::FORGE_DTS
458 )),
459 server_info: Implementation {
460 name: "forge".into(),
461 version: env!("CARGO_PKG_VERSION").into(),
462 title: None,
463 description: None,
464 icons: None,
465 website_url: None,
466 },
467 ..Default::default()
468 }
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use forge_manifest::{Category, ManifestBuilder, ServerBuilder, ToolEntry};
476
477 struct TestDispatcher;
478
479 #[async_trait::async_trait]
480 impl ToolDispatcher for TestDispatcher {
481 async fn call_tool(
482 &self,
483 server: &str,
484 tool: &str,
485 args: serde_json::Value,
486 ) -> Result<serde_json::Value, forge_error::DispatchError> {
487 Ok(serde_json::json!({
488 "server": server,
489 "tool": tool,
490 "args": args,
491 "status": "ok"
492 }))
493 }
494 }
495
496 fn test_server() -> ForgeServer {
497 let manifest = ManifestBuilder::new()
498 .add_server(
499 ServerBuilder::new("test-server", "A test server")
500 .add_category(Category {
501 name: "tools".into(),
502 description: "Test tools".into(),
503 tools: vec![ToolEntry {
504 name: "echo".into(),
505 description: "Echoes input".into(),
506 params: vec![],
507 returns: Some("The input".into()),
508 input_schema: None,
509 }],
510 })
511 .build(),
512 )
513 .build();
514 let dispatcher: Arc<dyn ToolDispatcher> = Arc::new(TestDispatcher);
515 ForgeServer::new(SandboxConfig::default(), manifest, dispatcher, None)
516 }
517
518 #[test]
519 fn get_info_returns_correct_metadata() {
520 let server = test_server();
521 let info = server.get_info();
522 assert_eq!(info.server_info.name, "forge");
523 assert_eq!(info.server_info.version, env!("CARGO_PKG_VERSION"));
524 let instructions = info.instructions.unwrap();
525 assert!(instructions.contains("search()"));
526 assert!(instructions.contains("execute()"));
527 assert!(instructions.contains("1 servers, 1 tools"));
528 assert!(
530 instructions.contains("async arrow function"),
531 "instructions should mention async arrow function format"
532 );
533 assert!(
534 instructions.contains("Object (NOT array)"),
535 "instructions should warn about categories being an Object"
536 );
537 assert!(
538 instructions.contains("input_schema"),
539 "instructions should mention input_schema for parameter discovery"
540 );
541 assert!(
542 instructions.contains("no filesystem"),
543 "instructions should mention sandbox constraints"
544 );
545 assert!(
546 instructions.contains("use the tool name only"),
547 "instructions should clarify tool name vs category-prefixed form"
548 );
549 }
550
551 #[tokio::test]
552 async fn search_returns_json() {
553 let server = test_server();
554 let result = server
555 .search(Parameters(SearchInput {
556 code: r#"async () => { return manifest.servers.map(s => s.name); }"#.into(),
557 }))
558 .await;
559 match result {
560 Ok(json) => {
561 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
562 let names = parsed.as_array().unwrap();
563 assert_eq!(names[0], "test-server");
564 }
565 Err(e) => panic!("search should succeed: {e}"),
566 }
567 }
568
569 #[tokio::test]
570 async fn search_with_invalid_code_returns_error() {
571 let server = test_server();
572 let result = server
573 .search(Parameters(SearchInput {
574 code: r#"async () => { return eval("bad"); }"#.into(),
576 }))
577 .await;
578 assert!(result.is_ok(), "should return Ok with error JSON");
581 let json = result.unwrap();
582 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
583 assert!(
584 parsed["error"].as_str().unwrap().contains("banned pattern"),
585 "error should mention banned pattern: {parsed}"
586 );
587 }
588
589 #[tokio::test]
590 async fn execute_calls_tool() {
591 let server = test_server();
592 let result = server
593 .execute(Parameters(ExecuteInput {
594 code: r#"async () => {
595 return await forge.callTool("test-server", "tools.echo", { msg: "hi" });
596 }"#
597 .into(),
598 }))
599 .await;
600 match result {
601 Ok(json) => {
602 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
603 assert_eq!(parsed["server"], "test-server");
604 assert_eq!(parsed["tool"], "tools.echo");
605 assert_eq!(parsed["status"], "ok");
606 }
607 Err(e) => panic!("execute should succeed: {e}"),
608 }
609 }
610
611 #[tokio::test]
612 async fn execute_with_banned_code_returns_error() {
613 let server = test_server();
614 let result = server
615 .execute(Parameters(ExecuteInput {
616 code: r#"async () => { return eval("bad"); }"#.into(),
617 }))
618 .await;
619 assert!(result.is_ok(), "should return Ok with error JSON");
621 let json = result.unwrap();
622 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
623 assert!(
624 parsed["error"].as_str().unwrap().contains("banned pattern"),
625 "error should mention banned pattern: {parsed}"
626 );
627 }
628
629 #[tokio::test]
630 async fn empty_code_returns_error() {
631 let server = test_server();
632 let result = server
633 .search(Parameters(SearchInput { code: " ".into() }))
634 .await;
635 assert!(result.is_ok(), "should return Ok with error JSON");
637 let json = result.unwrap();
638 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
639 assert!(
640 parsed["error"].as_str().unwrap().contains("empty"),
641 "error should mention empty: {parsed}"
642 );
643 }
644
645 #[test]
648 fn truncate_result_short_passthrough() {
649 let short = r#"{"data": "hello"}"#.to_string();
650 let result = truncate_result_if_needed(short.clone());
651 assert_eq!(result, short, "short strings should pass through unchanged");
652 }
653
654 #[test]
655 fn truncate_result_long_truncates() {
656 let long = "x".repeat(MAX_RESULT_CHARS + 1000);
658 let result = truncate_result_if_needed(long.clone());
659
660 let parsed: serde_json::Value =
662 serde_json::from_str(&result).expect("truncated result should be valid JSON");
663 assert_eq!(parsed["_truncated"], true);
664 assert_eq!(parsed["_original_chars"], long.len());
665 let shown = parsed["_shown_chars"].as_u64().unwrap() as usize;
666 assert!(
667 shown < long.len(),
668 "shown chars should be less than original"
669 );
670 assert!(shown > 0, "should show some content");
671 let data = parsed["data"].as_str().unwrap();
672 assert_eq!(data.len(), shown, "data length should match _shown_chars");
673 }
674
675 #[test]
676 fn tr_02_truncate_cuts_at_newline() {
677 let mut obj = serde_json::Map::new();
679 for i in 0..5000 {
680 obj.insert(format!("key_{i}"), serde_json::json!(format!("value_{i}")));
681 }
682 let pretty = serde_json::to_string_pretty(&obj).unwrap();
683 assert!(
684 pretty.len() > MAX_RESULT_CHARS,
685 "test fixture should exceed limit"
686 );
687
688 let result = truncate_result_if_needed(pretty);
689 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
690 let data = parsed["data"].as_str().unwrap();
691 assert!(
693 data.ends_with('\n') || data.ends_with(','),
694 "should cut at newline or comma boundary, but ends with: {:?}",
695 data.chars().last()
696 );
697 }
698
699 #[test]
700 fn tr_03_truncate_envelope_is_valid_json() {
701 let long = "x".repeat(MAX_RESULT_CHARS + 500);
702 let result = truncate_result_if_needed(long);
703 let parsed: serde_json::Value =
704 serde_json::from_str(&result).expect("envelope should be valid JSON");
705 assert!(parsed.is_object(), "envelope should be a JSON object");
706 assert!(parsed.get("_truncated").is_some());
707 assert!(parsed.get("data").is_some());
708 }
709
710 #[test]
711 fn tr_04_truncate_data_fragment_flag() {
712 let long = "y".repeat(MAX_RESULT_CHARS + 100);
713 let result = truncate_result_if_needed(long);
714 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
715 assert_eq!(
716 parsed["_data_is_fragment"], true,
717 "truncated results should carry _data_is_fragment flag"
718 );
719 }
720
721 #[test]
722 fn tr_05_truncate_minified_json_fallback() {
723 let items: Vec<String> = (0..20000).map(|i| format!("\"item_{i}\"")).collect();
725 let minified = format!("[{}]", items.join(","));
726 assert!(minified.len() > MAX_RESULT_CHARS);
727
728 let result = truncate_result_if_needed(minified);
729 let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
730 assert_eq!(parsed["_truncated"], true);
731 let data = parsed["data"].as_str().unwrap();
732 let trimmed = data.trim_end();
734 assert!(
735 trimmed.ends_with(',') || trimmed.ends_with('"'),
736 "minified JSON should cut at comma: ends with {:?}",
737 trimmed.chars().last()
738 );
739 }
740
741 #[test]
742 fn tr_06_truncate_unicode_safe() {
743 let emoji = "\u{1F600}"; let mut long = String::new();
746 while long.len() < MAX_RESULT_CHARS + 500 {
747 long.push_str(emoji);
748 }
749 let result = truncate_result_if_needed(long);
750 let parsed: serde_json::Value =
752 serde_json::from_str(&result).expect("unicode truncation should produce valid JSON");
753 assert_eq!(parsed["_truncated"], true);
754 }
755
756 #[test]
757 fn tr_07_format_sandbox_result_ok() {
758 let value = serde_json::json!({"result": "hello"});
759 let result = format_sandbox_result(Ok::<_, String>(value));
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["result"], "hello");
764 }
765
766 #[test]
767 fn tr_08_format_sandbox_result_err_strips_prefix() {
768 let err = "javascript error: some problem";
769 let result = format_sandbox_result(Err::<serde_json::Value, _>(err));
770 assert!(result.is_ok());
771 let json = result.unwrap();
772 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
773 assert_eq!(parsed["error"], "some problem");
774 }
775
776 #[test]
779 fn dts_01_instructions_contain_typescript_defs() {
780 let server = test_server();
781 let info = server.get_info();
782 let instructions = info.instructions.unwrap();
783 assert!(
784 instructions.contains("callTool"),
785 "instructions should contain callTool: {instructions}"
786 );
787 }
788
789 #[test]
790 fn dts_02_instructions_contain_forge_interface() {
791 let server = test_server();
792 let info = server.get_info();
793 let instructions = info.instructions.unwrap();
794 assert!(
795 instructions.contains("interface") || instructions.contains("Forge"),
796 "instructions should contain Forge interface"
797 );
798 }
799
800 #[test]
801 fn dts_03_instructions_contain_stash_types() {
802 let server = test_server();
803 let info = server.get_info();
804 let instructions = info.instructions.unwrap();
805 assert!(
806 instructions.contains("ForgeStash"),
807 "instructions should contain ForgeStash type"
808 );
809 }
810}