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::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 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 code: r#"async () => { return eval("bad"); }"#.into(),
566 }))
567 .await;
568 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 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 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 #[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 let long = "x".repeat(MAX_RESULT_CHARS + 1000);
648 let result = truncate_result_if_needed(long.clone());
649
650 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 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 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 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 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 let emoji = "\u{1F600}"; 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 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 #[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}