1use std::sync::Arc;
7use tokio::sync::mpsc;
8
9use rmcp::handler::server::{router::tool::ToolRouter, wrapper::Parameters};
10use rmcp::model::{
11 CallToolResult, Content, Implementation, InitializeRequestParams, InitializeResult,
12 ServerCapabilities,
13};
14use rmcp::service::{RequestContext, RoleServer};
15use rmcp::{tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler};
16use schemars::JsonSchema;
17use serde::{Deserialize, Deserializer};
18use tracing::info;
19
20fn deserialize_option_u16_flexible<'de, D>(deserializer: D) -> Result<Option<u16>, D::Error>
22where
23 D: Deserializer<'de>,
24{
25 #[derive(Deserialize)]
26 #[serde(untagged)]
27 enum NumOrString {
28 Number(u16),
29 String(String),
30 }
31 match Option::<NumOrString>::deserialize(deserializer)? {
32 Some(NumOrString::Number(n)) => Ok(Some(n)),
33 Some(NumOrString::String(s)) => s.parse().map(Some).map_err(serde::de::Error::custom),
34 None => Ok(None),
35 }
36}
37
38fn deserialize_u32_flexible<'de, D>(deserializer: D) -> Result<u32, D::Error>
40where
41 D: Deserializer<'de>,
42{
43 #[derive(Deserialize)]
44 #[serde(untagged)]
45 enum NumOrString {
46 Number(u32),
47 String(String),
48 }
49 match NumOrString::deserialize(deserializer)? {
50 NumOrString::Number(n) => Ok(n),
51 NumOrString::String(s) => s.parse().map_err(serde::de::Error::custom),
52 }
53}
54
55fn deserialize_u64_flexible<'de, D>(deserializer: D) -> Result<u64, D::Error>
57where
58 D: Deserializer<'de>,
59{
60 #[derive(Deserialize)]
61 #[serde(untagged)]
62 enum NumOrString {
63 Number(u64),
64 String(String),
65 }
66 match NumOrString::deserialize(deserializer)? {
67 NumOrString::Number(n) => Ok(n),
68 NumOrString::String(s) => s.parse().map_err(serde::de::Error::custom),
69 }
70}
71
72fn deserialize_option_u32_flexible<'de, D>(deserializer: D) -> Result<Option<u32>, D::Error>
74where
75 D: Deserializer<'de>,
76{
77 #[derive(Deserialize)]
78 #[serde(untagged)]
79 enum NumOrString {
80 Number(u32),
81 String(String),
82 }
83 match Option::<NumOrString>::deserialize(deserializer)? {
84 Some(NumOrString::Number(n)) => Ok(Some(n)),
85 Some(NumOrString::String(s)) => s.parse().map(Some).map_err(serde::de::Error::custom),
86 None => Ok(None),
87 }
88}
89
90use crate::cli::Args;
91use crate::llm::agent_queue::LlmRequestQueue;
92use crate::llm::OllamaClient;
93use crate::settings::Settings;
94use crate::state::app_state::AppState;
95
96pub(crate) struct SharedState {
101 app_state: AppState,
102 llm_client: OllamaClient,
103 agent_queue: Option<Arc<LlmRequestQueue>>,
106 _status_tx: mpsc::UnboundedSender<String>,
107 background_tasks: Vec<tokio::task::JoinHandle<()>>,
108 task_owner: tokio_util::sync::CancellationToken,
109 _task_ticker: tokio::task::JoinHandle<()>,
112}
113
114impl Drop for SharedState {
115 fn drop(&mut self) {
116 self.task_owner.cancel();
117 for task in &self.background_tasks {
118 task.abort();
119 }
120 }
121}
122
123#[derive(Clone)]
125pub struct NetGetMcpService {
126 state: Arc<SharedState>,
127 tool_router: ToolRouter<Self>,
128}
129
130#[derive(Debug, Deserialize, JsonSchema)]
133pub struct ListProtocolsParams {
134 #[serde(default = "default_all")]
136 pub r#type: Option<String>,
137 #[serde(default)]
139 pub include_disabled: Option<bool>,
140}
141
142fn default_all() -> Option<String> {
143 Some("all".to_string())
144}
145
146#[derive(Debug, Deserialize, JsonSchema)]
147pub struct StartServerParams {
148 pub protocol: String,
150 #[serde(default, deserialize_with = "deserialize_option_u16_flexible")]
155 pub port: Option<u16>,
156 #[serde(default)]
163 pub instruction: Option<String>,
164 #[serde(default)]
166 pub host: Option<String>,
167 #[serde(default)]
190 pub event_handlers: Option<Vec<serde_json::Value>>,
191 #[serde(default)]
197 pub startup_params: Option<serde_json::Value>,
198 #[serde(default)]
203 pub interface: Option<String>,
204 #[serde(default)]
207 pub mac_address: Option<String>,
208 #[serde(default)]
215 pub send_first: Option<bool>,
216 #[serde(default)]
219 pub initial_memory: Option<String>,
220 #[serde(default)]
223 pub feedback_instructions: Option<String>,
224 #[serde(default)]
230 pub scheduled_tasks: Option<Vec<serde_json::Value>>,
231}
232
233#[derive(Debug, Deserialize, JsonSchema)]
234pub struct StartClientParams {
235 pub protocol: String,
239 pub remote_addr: String,
241 #[serde(default)]
246 pub instruction: Option<String>,
247 #[serde(default)]
254 pub event_handlers: Option<Vec<serde_json::Value>>,
255 #[serde(default)]
258 pub startup_params: Option<serde_json::Value>,
259 #[serde(default)]
261 pub initial_memory: Option<String>,
262 #[serde(default)]
265 pub feedback_instructions: Option<String>,
266 #[serde(default)]
270 pub scheduled_tasks: Option<Vec<serde_json::Value>>,
271}
272
273#[derive(Debug, Deserialize, JsonSchema)]
274pub struct StopClientParams {
275 #[serde(deserialize_with = "deserialize_u32_flexible")]
277 pub client_id: u32,
278}
279
280#[derive(Debug, Deserialize, JsonSchema)]
281pub struct ClientStatusParams {
282 #[serde(deserialize_with = "deserialize_u32_flexible")]
284 pub client_id: u32,
285}
286
287#[derive(Debug, Deserialize, JsonSchema)]
288pub struct StopServerParams {
289 #[serde(deserialize_with = "deserialize_u32_flexible")]
291 pub server_id: u32,
292}
293
294#[derive(Debug, Deserialize, JsonSchema)]
295pub struct ServerStatusParams {
296 #[serde(deserialize_with = "deserialize_u32_flexible")]
298 pub server_id: u32,
299}
300
301#[derive(Debug, Deserialize, JsonSchema)]
302pub struct SetModelParams {
303 pub model: String,
305}
306
307#[derive(Debug, Deserialize, JsonSchema)]
308pub struct GetProtocolDocsParams {
309 pub protocol: String,
311}
312
313#[derive(Debug, Deserialize, JsonSchema)]
314pub struct ListAccessLogsParams {
315 #[serde(default, deserialize_with = "deserialize_option_u32_flexible")]
317 pub limit: Option<u32>,
318}
319
320#[derive(Debug, Deserialize, JsonSchema)]
321pub struct GetAccessLogParams {
322 #[serde(deserialize_with = "deserialize_u64_flexible")]
324 pub id: u64,
325}
326
327#[derive(Debug, Deserialize, JsonSchema)]
328pub struct UpdateServerInstructionParams {
329 #[serde(deserialize_with = "deserialize_u32_flexible")]
331 pub server_id: u32,
332 pub instruction: String,
334}
335
336#[derive(Debug, Deserialize, JsonSchema)]
337pub struct UpdateServerParams {
338 #[serde(deserialize_with = "deserialize_u32_flexible")]
340 pub server_id: u32,
341 #[serde(default)]
343 pub instruction: Option<String>,
344 #[serde(default)]
346 pub event_handlers: Option<Vec<serde_json::Value>>,
347 #[serde(default)]
349 pub initial_memory: Option<String>,
350 #[serde(default)]
353 pub startup_params: Option<serde_json::Value>,
354 #[serde(default, deserialize_with = "deserialize_option_u16_flexible")]
356 pub port: Option<u16>,
357 #[serde(default)]
359 pub host: Option<String>,
360 #[serde(default)]
362 pub feedback_instructions: Option<String>,
363 #[serde(default)]
365 pub scheduled_tasks: Option<Vec<serde_json::Value>>,
366}
367
368#[derive(Debug, Deserialize, JsonSchema)]
369pub struct UpdateClientParams {
370 #[serde(deserialize_with = "deserialize_u32_flexible")]
372 pub client_id: u32,
373 #[serde(default)]
375 pub instruction: Option<String>,
376 #[serde(default)]
378 pub event_handlers: Option<Vec<serde_json::Value>>,
379 #[serde(default)]
381 pub initial_memory: Option<String>,
382 #[serde(default)]
384 pub startup_params: Option<serde_json::Value>,
385 #[serde(default)]
387 pub remote_addr: Option<String>,
388 #[serde(default)]
390 pub feedback_instructions: Option<String>,
391 #[serde(default)]
393 pub scheduled_tasks: Option<Vec<serde_json::Value>>,
394}
395
396#[derive(Debug, Deserialize, JsonSchema)]
397pub struct GetNextLlmRequestParams {
398 #[serde(default, deserialize_with = "deserialize_option_u32_flexible")]
401 pub wait_seconds: Option<u32>,
402}
403
404#[derive(Debug, Deserialize, JsonSchema)]
405pub struct AnswerLlmRequestParams {
406 #[serde(deserialize_with = "deserialize_u64_flexible")]
408 pub request_id: u64,
409 pub actions: Vec<serde_json::Value>,
412}
413
414#[derive(Debug, Deserialize, JsonSchema)]
415pub struct SendToClientParams {
416 #[serde(deserialize_with = "deserialize_u32_flexible")]
418 pub client_id: u32,
419 pub action: serde_json::Value,
423 #[serde(default, deserialize_with = "deserialize_option_u32_flexible")]
426 pub timeout_secs: Option<u32>,
427}
428
429#[derive(Debug, Deserialize, JsonSchema)]
430pub struct SendToPeerParams {
431 #[serde(deserialize_with = "deserialize_u32_flexible")]
433 pub server_id: u32,
434 #[serde(deserialize_with = "deserialize_u32_flexible")]
437 pub connection_id: u32,
438 pub action: serde_json::Value,
441 #[serde(default, deserialize_with = "deserialize_option_u32_flexible")]
444 pub timeout_secs: Option<u32>,
445}
446
447#[derive(Debug, Deserialize, JsonSchema)]
448pub struct DisconnectPeerParams {
449 #[serde(deserialize_with = "deserialize_u32_flexible")]
451 pub server_id: u32,
452 #[serde(deserialize_with = "deserialize_u32_flexible")]
454 pub connection_id: u32,
455}
456
457#[derive(Debug, Deserialize, JsonSchema)]
458pub struct AnswerInterceptParams {
459 #[serde(deserialize_with = "deserialize_u64_flexible")]
461 pub intercept_id: u64,
462 pub actions: Vec<serde_json::Value>,
468}
469
470#[derive(Debug, Deserialize, JsonSchema)]
471pub struct FailInterceptParams {
472 #[serde(deserialize_with = "deserialize_u64_flexible")]
474 pub intercept_id: u64,
475}
476
477const INJECT_TIMEOUT_DEFAULT_SECS: u32 = 30;
479const INJECT_TIMEOUT_MAX_SECS: u32 = 120;
481
482#[cfg(unix)]
484fn ensure_fifo(path: &std::path::Path) -> anyhow::Result<()> {
485 use std::os::unix::ffi::OsStrExt;
486 use std::os::unix::fs::FileTypeExt;
487 match std::fs::symlink_metadata(path) {
488 Ok(metadata) if metadata.file_type().is_fifo() => return Ok(()),
489 Ok(_) => anyhow::bail!(
490 "--llm-agent-pipe path {} must be a FIFO, not a regular file, directory, or symlink",
491 path.display()
492 ),
493 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
494 Err(error) => return Err(error.into()),
495 }
496 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())
497 .map_err(|_| anyhow::anyhow!("invalid FIFO path: {}", path.display()))?;
498 let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
500 if rc != 0 {
501 return Err(std::io::Error::last_os_error())
502 .map_err(|e| anyhow::anyhow!("failed to create FIFO {}: {}", path.display(), e));
503 }
504 Ok(())
505}
506
507#[cfg(not(unix))]
508fn ensure_fifo(_path: &std::path::Path) -> anyhow::Result<()> {
509 anyhow::bail!("--llm-agent-pipe (named pipes) is only supported on Unix platforms")
510}
511
512const REAPER_INTERVAL_SECS: u64 = 5;
514const SERVER_CLEANUP_TIMEOUT_SECS: u64 = 30;
516const CONNECTION_CLEANUP_TIMEOUT_SECS: u64 = 10;
518const CONNECTIONLESS_CLEANUP_TIMEOUT_SECS: u64 = 10;
520
521fn spawn_state_reaper(
539 app_state: AppState,
540 llm_client: crate::llm::OllamaClient,
541 status_tx: mpsc::UnboundedSender<String>,
542) -> tokio::task::JoinHandle<()> {
543 tokio::spawn(async move {
544 let mut ticker =
545 tokio::time::interval(std::time::Duration::from_secs(REAPER_INTERVAL_SECS));
546 loop {
547 ticker.tick().await;
548 crate::llm::feedback::execute_due_feedback(&app_state, &llm_client, &status_tx).await;
549 app_state
550 .cleanup_old_servers(SERVER_CLEANUP_TIMEOUT_SECS)
551 .await;
552 app_state
553 .cleanup_closed_connections(CONNECTION_CLEANUP_TIMEOUT_SECS)
554 .await;
555 app_state
556 .cleanup_old_connections(CONNECTIONLESS_CLEANUP_TIMEOUT_SECS)
557 .await;
558 app_state.cleanup_old_conversations().await;
559 }
560 })
561}
562
563const TASK_TICK_INTERVAL_SECS: u64 = 1;
568
569fn spawn_task_ticker(
586 app_state: AppState,
587 llm_client: crate::llm::OllamaClient,
588 status_tx: mpsc::UnboundedSender<String>,
589 owner: tokio_util::sync::CancellationToken,
590) -> tokio::task::JoinHandle<()> {
591 tokio::spawn(async move {
592 let mut ticker =
593 tokio::time::interval(std::time::Duration::from_secs(TASK_TICK_INTERVAL_SECS));
594 ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
597 loop {
598 tokio::select! {
599 biased;
600 _ = owner.cancelled() => break,
601 _ = ticker.tick() => {}
602 }
603 crate::cli::execute_due_tasks_owned_public(
604 &app_state,
605 &llm_client,
606 &status_tx,
607 owner.clone(),
608 )
609 .await;
610 }
611 })
612}
613
614fn bound_port(server: &crate::state::server::ServerInstance) -> u16 {
627 server
628 .local_addr
629 .map(|a| a.port())
630 .filter(|p| *p != 0)
631 .unwrap_or(server.port)
632}
633
634#[tool_router]
637
638impl NetGetMcpService {
639 pub async fn new(args: &Args, settings: Settings) -> anyhow::Result<Self> {
640 let state = Self::create_shared_state(args, settings).await?;
641 Ok(Self::new_with_shared_state(state))
642 }
643
644 pub(crate) async fn create_shared_state(
650 args: &Args,
651 settings: Settings,
652 ) -> anyhow::Result<Arc<SharedState>> {
653 let base_url = args
666 .openai_url
667 .clone()
668 .or_else(|| args.ollama_url.clone())
669 .unwrap_or_else(|| "http://localhost:11434".to_string());
670 let app_state = AppState::new_with_options(args.include_disabled_protocols, base_url);
671 app_state
672 .set_min_stability(args.parse_min_stability()?)
673 .await;
674
675 app_state
676 .configure_rate_limiter(args.build_rate_limiter_config())
677 .await?;
678
679 if let Some(mode) = args
684 .parse_scripting_mode()?
685 .or_else(|| settings.parse_scripting_mode())
686 {
687 let scripting_env = app_state.get_scripting_env().await;
688 let available = match mode {
689 crate::state::app_state::ScriptingMode::On
690 | crate::state::app_state::ScriptingMode::Off => true,
691 crate::state::app_state::ScriptingMode::Python => scripting_env.python.is_some(),
692 crate::state::app_state::ScriptingMode::JavaScript => {
693 scripting_env.javascript.is_some()
694 }
695 crate::state::app_state::ScriptingMode::Go => scripting_env.go.is_some(),
696 crate::state::app_state::ScriptingMode::Perl => scripting_env.perl.is_some(),
697 };
698 if available {
699 app_state.set_selected_scripting_mode(mode).await;
700 } else {
701 tracing::warn!(
702 "Requested scripting environment {} is not available on this system; leaving scripting mode unchanged",
703 mode
704 );
705 }
706 }
707
708 if let Some(handler_mode) = args.parse_event_handler_mode()? {
709 app_state.set_event_handler_mode(handler_mode).await;
710 }
711
712 let mut web_search_mode = settings.get_web_search_mode();
715 if web_search_mode == crate::state::app_state::WebSearchMode::Ask {
716 web_search_mode = crate::state::app_state::WebSearchMode::Off;
717 }
718 app_state.set_web_search_mode(web_search_mode).await;
719
720 let (llm_client, agent_queue) = if args.llm_agent {
725 let pipe = args.llm_agent_pipe.clone();
726 if let Some(ref path) = pipe {
727 ensure_fifo(path)?;
728 }
729 let queue = Arc::new(LlmRequestQueue::new(pipe));
730 let timeout = std::time::Duration::from_secs(args.llm_agent_timeout);
731 let client = OllamaClient::new_queue(queue.clone(), timeout);
732
733 if args.model.is_none() {
736 app_state.set_ollama_model(Some("agent".to_string())).await;
737 }
738 (client, Some(queue))
739 } else {
740 let client = crate::cli::create_llm_client(args)?
745 .with_mock_config_file(args.mock_config_file.clone())
746 .with_app_state(app_state.clone());
747 (client, None)
748 };
749
750 let (status_tx, mut status_rx) = mpsc::unbounded_channel();
752
753 let configured_model = args.model.clone().or_else(|| {
759 if args.llm_agent {
760 None
761 } else {
762 settings.model.clone()
763 }
764 });
765 if let Some(model) = configured_model {
766 app_state.set_ollama_model(Some(model)).await;
767 }
768
769 let reaper = spawn_state_reaper(app_state.clone(), llm_client.clone(), status_tx.clone());
775
776 let task_owner = tokio_util::sync::CancellationToken::new();
780 let ticker = spawn_task_ticker(
781 app_state.clone(),
782 llm_client.clone(),
783 status_tx.clone(),
784 task_owner.clone(),
785 );
786
787 let status_drain = tokio::spawn(async move {
789 while let Some(msg) = status_rx.recv().await {
790 eprintln!("[NETGET] {}", msg);
791 }
792 });
793 let state = Arc::new(SharedState {
794 app_state,
795 llm_client,
796 agent_queue,
797 _status_tx: status_tx,
798 background_tasks: vec![reaper, status_drain],
799 task_owner,
800 _task_ticker: ticker,
801 });
802
803 Ok(state)
804 }
805
806 pub(crate) fn new_with_shared_state(state: Arc<SharedState>) -> Self {
809 Self {
810 state,
811 tool_router: Self::tool_router(),
812 }
813 }
814
815 #[tool(
816 description = "List all available network protocols that can be started as servers or clients"
817 )]
818 async fn list_protocols(
819 &self,
820 Parameters(params): Parameters<ListProtocolsParams>,
821 ) -> Result<CallToolResult, McpError> {
822 let filter_type = params.r#type.as_deref().unwrap_or("all");
823 let include_disabled = params.include_disabled.unwrap_or(false);
824 let mut result = String::new();
825
826 if filter_type == "all" || filter_type == "server" {
827 let registry = crate::protocol::server_registry::registry();
828 let protocols = registry.available_protocols();
829 result.push_str("## Server Protocols\n\n");
830 for name in &protocols {
831 if let Some(proto) = registry.get(name) {
832 let metadata = proto.metadata();
833 if !include_disabled
834 && matches!(
835 metadata.state,
836 crate::protocol::metadata::DevelopmentState::Incomplete
837 )
838 {
839 continue;
840 }
841 result.push_str(&format!(
842 "- **{}** ({}) - {}\n",
843 name,
844 metadata.state.as_str(),
845 metadata.implementation
846 ));
847 }
848 }
849 result.push('\n');
850 }
851
852 if filter_type == "all" || filter_type == "client" {
853 let registry = &crate::protocol::CLIENT_REGISTRY;
854 let mut protocols = registry.list_protocols();
855 protocols.sort();
856 result.push_str("## Client Protocols\n\n");
857 result.push_str("Start one with `start_client` (needs a `remote_addr`).\n\n");
858 for name in &protocols {
859 if let Some(proto) = registry.get(name) {
860 let metadata = proto.metadata();
861 if !include_disabled
862 && matches!(
863 metadata.state,
864 crate::protocol::metadata::DevelopmentState::Incomplete
865 )
866 {
867 continue;
868 }
869 result.push_str(&format!(
870 "- **{}** ({}) - {}\n",
871 name,
872 metadata.state.as_str(),
873 proto.description()
874 ));
875 } else {
876 result.push_str(&format!("- **{}**\n", name));
877 }
878 }
879 result.push('\n');
880 }
881
882 Ok(CallToolResult::success(vec![Content::text(result)]))
883 }
884
885 #[tool(
886 description = "Start a network protocol server. Choose HOW it responds: for DETERMINISTIC behavior (echo, fixed/canned responses, simple routing, high throughput) pass `event_handlers` with a script or static handler — these run in-process with NO LLM call (instant, free, reproducible). Only use the natural-language `instruction` when responses genuinely need reasoning or vary unpredictably; it invokes NetGet's LLM on every request. Prefer event_handlers whenever the logic is fixed."
887 )]
888 async fn start_server(
889 &self,
890 Parameters(params): Parameters<StartServerParams>,
891 ) -> Result<CallToolResult, McpError> {
892 let instruction = params.instruction.unwrap_or_else(|| {
893 format!(
894 "You are a {} server. Handle requests appropriately.",
895 params.protocol
896 )
897 });
898
899 self.state
901 .app_state
902 .set_llm_client(self.state.llm_client.clone())
903 .await;
904
905 info!(
906 "MCP: Starting {} server on {}:{}",
907 params.protocol,
908 params.host.as_deref().unwrap_or("127.0.0.1"),
909 params.port.unwrap_or(0)
910 );
911
912 let scheduled_tasks = match parse_scheduled_tasks(params.scheduled_tasks) {
913 Ok(tasks) => tasks,
914 Err(e) => {
915 return Ok(CallToolResult::error(vec![Content::text(format!(
916 "Invalid scheduled_tasks: {}",
917 e
918 ))]));
919 }
920 };
921
922 let (status_tx, mut status_rx) = mpsc::unbounded_channel();
923 let server_id = match crate::cli::server_startup::start_server_from_action(
924 &self.state.app_state,
925 params.mac_address.clone(), params.interface.clone(), params.host.clone(), params.port, ¶ms.protocol, params.send_first.unwrap_or(false), params.initial_memory, instruction, params.startup_params, params.event_handlers, scheduled_tasks, params.feedback_instructions, status_tx, )
939 .await
940 {
941 Ok(id) => id,
942 Err(e) => {
943 return Ok(CallToolResult::error(vec![Content::text(format!(
944 "Failed to start server: {}",
945 e
946 ))]));
947 }
948 };
949
950 let mut messages = Vec::new();
952 while let Ok(msg) = status_rx.try_recv() {
953 messages.push(msg);
954 }
955
956 let result = format!(
957 "Server #{} ({}) started.\n{}",
958 server_id.as_u32(),
959 params.protocol,
960 messages.join("\n")
961 );
962
963 Ok(CallToolResult::success(vec![Content::text(result)]))
964 }
965
966 #[tool(description = "Stop a running server by its ID")]
967 async fn stop_server(
968 &self,
969 Parameters(params): Parameters<StopServerParams>,
970 ) -> Result<CallToolResult, McpError> {
971 let server_id = crate::state::ServerId::new(params.server_id);
972
973 match self.state.app_state.get_server(server_id).await {
974 Some(server) => {
975 let protocol = server.protocol_name.clone();
976 let port = bound_port(&server);
977 self.state.app_state.remove_server(server_id).await;
978 Ok(CallToolResult::success(vec![Content::text(format!(
979 "Server #{} ({} on port {}) stopped",
980 params.server_id, protocol, port
981 ))]))
982 }
983 None => Ok(CallToolResult::error(vec![Content::text(format!(
984 "Server #{} not found",
985 params.server_id
986 ))])),
987 }
988 }
989
990 #[tool(
991 description = "List all running servers with their status, protocol, port, and connection count"
992 )]
993 async fn list_servers(&self) -> Result<CallToolResult, McpError> {
994 let servers = self.state.app_state.get_all_servers().await;
995
996 if servers.is_empty() {
997 return Ok(CallToolResult::success(vec![Content::text(
998 "No servers currently running.",
999 )]));
1000 }
1001
1002 let mut result = String::from("## Running Servers\n\n");
1003 for server in &servers {
1004 result.push_str(&format!(
1005 "- **Server #{}**: {} on port {} ({})\n Instruction: {}\n Memory: {}\n\n",
1006 server.id.as_u32(),
1007 server.protocol_name,
1008 bound_port(server),
1009 server.status,
1010 if server.instruction.is_empty() {
1011 "(none)"
1012 } else {
1013 &server.instruction
1014 },
1015 if server.memory.is_empty() {
1016 "(empty)"
1017 } else {
1018 &server.memory
1019 },
1020 ));
1021 }
1022
1023 Ok(CallToolResult::success(vec![Content::text(result)]))
1024 }
1025
1026 #[tool(description = "Get detailed status of a specific server")]
1027 async fn server_status(
1028 &self,
1029 Parameters(params): Parameters<ServerStatusParams>,
1030 ) -> Result<CallToolResult, McpError> {
1031 let server_id = crate::state::ServerId::new(params.server_id);
1032
1033 match self.state.app_state.get_server(server_id).await {
1034 Some(server) => {
1035 let result = format!(
1036 "## Server #{}\n\n\
1037 - **Protocol**: {}\n\
1038 - **Port**: {}\n\
1039 - **Status**: {}\n\
1040 - **Instruction**: {}\n\
1041 - **Memory**: {}\n",
1042 server.id.as_u32(),
1043 server.protocol_name,
1044 bound_port(&server),
1045 server.status,
1046 if server.instruction.is_empty() {
1047 "(none)"
1048 } else {
1049 &server.instruction
1050 },
1051 if server.memory.is_empty() {
1052 "(empty)"
1053 } else {
1054 &server.memory
1055 },
1056 );
1057 let mut result = result;
1058
1059 let mut connections: Vec<_> = server.connections.values().collect();
1062 connections.sort_by_key(|c| c.id.as_u32());
1063 if connections.is_empty() {
1064 result.push_str("- **Connections**: none\n");
1065 } else {
1066 result.push_str("\n### Connections\n\n");
1067 if let Some(reason) = crate::protocol::server_registry::registry()
1068 .request_only_reason(&server.protocol_name)
1069 {
1070 result.push_str(&format!(
1071 "No connection accepts send_to_peer: {}.\n\n",
1072 reason
1073 ));
1074 }
1075 for conn in connections {
1076 let injectable = self
1077 .state
1078 .app_state
1079 .has_peer_handle(server_id, conn.id.as_u32())
1080 .await;
1081 result.push_str(&format!(
1082 "- connection_id **{}** — {} ({:?}), {} byte(s) in / {} out{}\n",
1083 conn.id.as_u32(),
1084 conn.remote_addr,
1085 conn.status,
1086 conn.bytes_received,
1087 conn.bytes_sent,
1088 if injectable {
1089 " — accepts send_to_peer / disconnect_peer"
1090 } else {
1091 ""
1092 }
1093 ));
1094 }
1095 }
1096 Ok(CallToolResult::success(vec![Content::text(result)]))
1097 }
1098 None => Ok(CallToolResult::error(vec![Content::text(format!(
1099 "Server #{} not found",
1100 params.server_id
1101 ))])),
1102 }
1103 }
1104
1105 #[tool(
1106 description = "Get overall NetGet status including the configured model and running servers"
1107 )]
1108 async fn get_status(&self) -> Result<CallToolResult, McpError> {
1109 let servers = self.state.app_state.get_all_servers().await;
1110 let model = self
1111 .state
1112 .app_state
1113 .get_ollama_model()
1114 .await
1115 .unwrap_or_else(|| "(auto-select)".to_string());
1116
1117 let mut result = format!(
1118 "## NetGet Status\n\n\
1119 - **Model**: {}\n\
1120 - **LLM backend**: {}\n\
1121 - **Running servers**: {}\n",
1122 model,
1123 self.state.llm_client.backend_type(),
1124 servers.len(),
1125 );
1126
1127 if !servers.is_empty() {
1128 result.push_str("\n### Servers\n");
1129 for server in &servers {
1130 result.push_str(&format!(
1131 "- #{}: {} on port {} ({})\n",
1132 server.id.as_u32(),
1133 server.protocol_name,
1134 bound_port(server),
1135 server.status
1136 ));
1137 }
1138 }
1139
1140 Ok(CallToolResult::success(vec![Content::text(result)]))
1141 }
1142
1143 #[tool(description = "Change the default LLM model used for new servers/clients")]
1144 async fn set_model(
1145 &self,
1146 Parameters(params): Parameters<SetModelParams>,
1147 ) -> Result<CallToolResult, McpError> {
1148 self.state
1149 .app_state
1150 .set_ollama_model(Some(params.model.clone()))
1151 .await;
1152 Ok(CallToolResult::success(vec![Content::text(format!(
1153 "Model changed to: {}",
1154 params.model
1155 ))]))
1156 }
1157
1158 #[tool(
1159 description = "Get MCP-shaped documentation for a protocol: the exact start_server/start_client arguments that apply, the protocol's event ids with their field names and types (needed to write an event_handlers script), its action names with parameter schemas and examples, its startup parameters, its privilege requirement, and its maturity."
1160 )]
1161 async fn get_protocol_docs(
1162 &self,
1163 Parameters(params): Parameters<GetProtocolDocsParams>,
1164 ) -> Result<CallToolResult, McpError> {
1165 match crate::mcp_stdio::docs::render_protocol_docs(¶ms.protocol, &self.state.app_state)
1166 .await
1167 {
1168 Some(text) => Ok(CallToolResult::success(vec![Content::text(text)])),
1169 None => {
1170 let server_registry = crate::protocol::server_registry::registry();
1172 let available = server_registry.available_protocols();
1173 Ok(CallToolResult::error(vec![Content::text(format!(
1174 "Protocol '{}' not found. It may not be compiled into this build.\n\n\
1175 Available server protocols ({}):\n{}\n\n\
1176 Build with the protocol feature enabled: cargo build --features mcp-stdio,{}",
1177 params.protocol,
1178 available.len(),
1179 available
1180 .iter()
1181 .map(|p| format!(" - {}", p))
1182 .collect::<Vec<_>>()
1183 .join("\n"),
1184 params.protocol.to_lowercase(),
1185 ))]))
1186 }
1187 }
1188 }
1189
1190 #[tool(description = "Update the LLM instruction for a running server")]
1191 async fn update_server_instruction(
1192 &self,
1193 Parameters(params): Parameters<UpdateServerInstructionParams>,
1194 ) -> Result<CallToolResult, McpError> {
1195 let server_id = crate::state::ServerId::new(params.server_id);
1196
1197 let updated = self
1198 .state
1199 .app_state
1200 .with_server_mut(server_id, |server| {
1201 server.instruction = params.instruction.clone();
1202 })
1203 .await;
1204
1205 match updated {
1206 Some(()) => Ok(CallToolResult::success(vec![Content::text(format!(
1207 "Server #{} instruction updated",
1208 params.server_id
1209 ))])),
1210 None => Ok(CallToolResult::error(vec![Content::text(format!(
1211 "Server #{} not found",
1212 params.server_id
1213 ))])),
1214 }
1215 }
1216
1217 #[tool(
1218 description = "Update a RUNNING server in place, by its id, instead of starting a second one. Supply only the fields to change. instruction / event_handlers / initial_memory / feedback_instructions / scheduled_tasks are hot-applied WITHOUT dropping connections; port / host / startup_params require a clean stop+start (connections dropped, server gets a NEW id). Unknown id and invalid startup_params error cleanly and leave the running server untouched. Prefer this over start_server when a server already exists."
1219 )]
1220 async fn update_server(
1221 &self,
1222 Parameters(params): Parameters<UpdateServerParams>,
1223 ) -> Result<CallToolResult, McpError> {
1224 self.state
1226 .app_state
1227 .set_llm_client(self.state.llm_client.clone())
1228 .await;
1229
1230 let scheduled_tasks = match parse_scheduled_tasks(params.scheduled_tasks) {
1231 Ok(t) => t,
1232 Err(e) => {
1233 return Ok(CallToolResult::error(vec![Content::text(format!(
1234 "Invalid scheduled_tasks: {}",
1235 e
1236 ))]))
1237 }
1238 };
1239
1240 let form = crate::cli::management::ServerForm {
1241 protocol: String::new(),
1242 port: params.port,
1243 host: params.host,
1244 interface: None,
1245 mac_address: None,
1246 send_first: false,
1247 instruction: params.instruction,
1248 initial_memory: params.initial_memory,
1249 startup_params: params.startup_params,
1250 event_handlers: params.event_handlers,
1251 scheduled_tasks,
1252 feedback_instructions: params.feedback_instructions,
1253 };
1254
1255 let (status_tx, _status_rx) = mpsc::unbounded_channel();
1256 match crate::cli::management::update_server(
1257 &self.state.app_state,
1258 crate::state::ServerId::new(params.server_id),
1259 form,
1260 status_tx,
1261 )
1262 .await
1263 {
1264 Ok(outcome) => Ok(CallToolResult::success(vec![Content::text(
1265 outcome.summary,
1266 )])),
1267 Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
1268 }
1269 }
1270
1271 #[tool(
1272 description = "Update a RUNNING client in place, by its id, instead of connecting a second one. Supply only the fields to change. instruction / event_handlers / initial_memory / feedback_instructions / scheduled_tasks are hot-applied; remote_addr / startup_params trigger a reconnect (new id). Unknown id and invalid startup_params error cleanly and leave the running client untouched."
1273 )]
1274 async fn update_client(
1275 &self,
1276 Parameters(params): Parameters<UpdateClientParams>,
1277 ) -> Result<CallToolResult, McpError> {
1278 self.state
1279 .app_state
1280 .set_llm_client(self.state.llm_client.clone())
1281 .await;
1282
1283 let scheduled_tasks = match parse_scheduled_tasks(params.scheduled_tasks) {
1284 Ok(t) => t,
1285 Err(e) => {
1286 return Ok(CallToolResult::error(vec![Content::text(format!(
1287 "Invalid scheduled_tasks: {}",
1288 e
1289 ))]))
1290 }
1291 };
1292
1293 let form = crate::cli::management::ClientForm {
1294 protocol: String::new(),
1295 remote_addr: params.remote_addr,
1296 instruction: params.instruction,
1297 initial_memory: params.initial_memory,
1298 startup_params: params.startup_params,
1299 event_handlers: params.event_handlers,
1300 scheduled_tasks,
1301 feedback_instructions: params.feedback_instructions,
1302 };
1303
1304 let (status_tx, _status_rx) = mpsc::unbounded_channel();
1305 match crate::cli::management::update_client(
1306 &self.state.app_state,
1307 crate::state::ClientId::new(params.client_id),
1308 form,
1309 self.state.llm_client.clone(),
1310 status_tx,
1311 )
1312 .await
1313 {
1314 Ok(outcome) => Ok(CallToolResult::success(vec![Content::text(
1315 outcome.summary,
1316 )])),
1317 Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
1318 }
1319 }
1320
1321 #[tool(
1322 description = "List recent request/response access-log entries across all servers (newest first). Use get_access_log with an entry id to see the full request and response."
1323 )]
1324 async fn list_access_logs(
1325 &self,
1326 Parameters(params): Parameters<ListAccessLogsParams>,
1327 ) -> Result<CallToolResult, McpError> {
1328 let limit = params.limit.unwrap_or(20) as usize;
1329 let entries = self.state.app_state.list_access_logs(Some(limit)).await;
1330
1331 if entries.is_empty() {
1332 return Ok(CallToolResult::success(vec![Content::text(
1333 "No requests logged yet. Access logs are recorded as servers handle requests.",
1334 )]));
1335 }
1336
1337 let now_ms = crate::utils::clock::SystemTime::now()
1338 .duration_since(crate::utils::clock::UNIX_EPOCH)
1339 .map(|d| d.as_millis() as u64)
1340 .unwrap_or(0);
1341
1342 let mut out = format!("## Recent requests ({} shown)\n\n", entries.len());
1343 for e in &entries {
1344 let age = now_ms.saturating_sub(e.unix_ms) / 1000;
1345 let owner = match (e.server_id, e.client_id) {
1346 (Some(id), _) => format!("server #{id}"),
1347 (None, Some(id)) => format!("client #{id}"),
1348 (None, None) => "unknown".to_string(),
1349 };
1350 out.push_str(&format!(
1351 "- **#{}** [{}s ago] {} ({}) — {} — {} → {}\n",
1352 e.id,
1353 age,
1354 owner,
1355 e.protocol,
1356 e.event_type,
1357 summarize_request(&e.request),
1358 summarize_response(&e.response),
1359 ));
1360 }
1361 out.push_str("\nUse `get_access_log` with an id for the full request and response.");
1362
1363 Ok(CallToolResult::success(vec![Content::text(out)]))
1364 }
1365
1366 #[tool(
1367 description = "Get the full request and response for a single access-log entry by id (from list_access_logs)"
1368 )]
1369 async fn get_access_log(
1370 &self,
1371 Parameters(params): Parameters<GetAccessLogParams>,
1372 ) -> Result<CallToolResult, McpError> {
1373 match self.state.app_state.get_access_log(params.id).await {
1374 Some(e) => {
1375 let now_ms = crate::utils::clock::SystemTime::now()
1376 .duration_since(crate::utils::clock::UNIX_EPOCH)
1377 .map(|d| d.as_millis() as u64)
1378 .unwrap_or(0);
1379 let age = now_ms.saturating_sub(e.unix_ms) / 1000;
1380 let request = serde_json::to_string_pretty(&e.request)
1381 .unwrap_or_else(|_| e.request.to_string());
1382 let response = serde_json::to_string_pretty(&e.response)
1383 .unwrap_or_else(|_| "[]".to_string());
1384 let owner = match (e.server_id, e.client_id) {
1385 (Some(id), _) => format!("Server #{id}"),
1386 (None, Some(id)) => format!("Client #{id}"),
1387 (None, None) => "Unknown".to_string(),
1388 };
1389 let result = format!(
1390 "## Access log #{}\n\n\
1391 - **Owner**: {} ({})\n\
1392 - **Connection**: {}\n\
1393 - **Event**: {}\n\
1394 - **When**: {}s ago (unix_ms {})\n\n\
1395 ### Request\n```json\n{}\n```\n\n\
1396 ### Response (actions)\n```json\n{}\n```\n",
1397 e.id,
1398 owner,
1399 e.protocol,
1400 e.connection_id
1401 .map(|c| c.to_string())
1402 .unwrap_or_else(|| "(connectionless)".to_string()),
1403 e.event_type,
1404 age,
1405 e.unix_ms,
1406 request,
1407 response,
1408 );
1409 Ok(CallToolResult::success(vec![Content::text(result)]))
1410 }
1411 None => Ok(CallToolResult::error(vec![Content::text(format!(
1412 "No access-log entry #{} (it may have aged out of the buffer). Use list_access_logs to see current entries.",
1413 params.id
1414 ))])),
1415 }
1416 }
1417
1418 #[tool(description = "Stop all running servers and connections")]
1419 async fn stop_all(&self) -> Result<CallToolResult, McpError> {
1420 let server_ids = self.state.app_state.get_all_server_ids().await;
1421 let count = server_ids.len();
1422
1423 for id in server_ids {
1424 self.state.app_state.remove_server(id).await;
1425 }
1426
1427 Ok(CallToolResult::success(vec![Content::text(format!(
1428 "Stopped {} server(s)",
1429 count
1430 ))]))
1431 }
1432
1433 #[tool(
1434 description = "Connect a network protocol client to a remote server. The mirror of start_server: choose HOW it responds the same way — for DETERMINISTIC behavior pass `event_handlers` with a script or static handler (in-process, NO LLM call, instant and reproducible); only use the natural-language `instruction` when the decision genuinely needs reasoning, since it invokes NetGet's LLM on every event. Use get_protocol_docs for the protocol's client event ids, action names and startup parameters."
1435 )]
1436 async fn start_client(
1437 &self,
1438 Parameters(params): Parameters<StartClientParams>,
1439 ) -> Result<CallToolResult, McpError> {
1440 let instruction = params.instruction.unwrap_or_else(|| {
1441 format!(
1442 "You are a {} client connected to {}. Handle responses appropriately.",
1443 params.protocol, params.remote_addr
1444 )
1445 });
1446
1447 let scheduled_tasks = match parse_scheduled_tasks(params.scheduled_tasks) {
1448 Ok(tasks) => tasks,
1449 Err(e) => {
1450 return Ok(CallToolResult::error(vec![Content::text(format!(
1451 "Invalid scheduled_tasks: {}",
1452 e
1453 ))]));
1454 }
1455 };
1456
1457 self.state
1461 .app_state
1462 .set_llm_client(self.state.llm_client.clone())
1463 .await;
1464
1465 info!(
1466 "MCP: Connecting {} client to {}",
1467 params.protocol, params.remote_addr
1468 );
1469
1470 let client_id = match crate::cli::client_startup::start_client_from_action(
1471 &self.state.app_state,
1472 ¶ms.protocol,
1473 ¶ms.remote_addr,
1474 instruction,
1475 params.startup_params,
1476 params.initial_memory,
1477 params.event_handlers,
1478 scheduled_tasks,
1479 params.feedback_instructions,
1480 self.state.llm_client.clone(),
1481 None, )
1483 .await
1484 {
1485 Ok(id) => id,
1486 Err(e) => {
1487 return Ok(CallToolResult::error(vec![Content::text(format!(
1488 "Failed to start client: {}",
1489 e
1490 ))]));
1491 }
1492 };
1493
1494 Ok(CallToolResult::success(vec![Content::text(format!(
1495 "Client #{} ({}) connected to {}.",
1496 client_id.as_u32(),
1497 params.protocol,
1498 params.remote_addr
1499 ))]))
1500 }
1501
1502 #[tool(
1503 description = "Stop a client by its ID. Aborts every background task the client owns (read loop, transport driver, in-flight requests), which releases its socket and stops any further LLM calls, then drops it from NetGet's state so it disappears from list_clients and client_status. Note that a handful of client protocols do not yet register their tasks; for those this still only drops the bookkeeping — see src/mcp_stdio/CLAUDE.md for the current list."
1504 )]
1505 async fn stop_client(
1506 &self,
1507 Parameters(params): Parameters<StopClientParams>,
1508 ) -> Result<CallToolResult, McpError> {
1509 let client_id = crate::state::ClientId::new(params.client_id);
1510
1511 match self.state.app_state.remove_client(client_id).await {
1512 Some(client) => Ok(CallToolResult::success(vec![Content::text(format!(
1513 "Client #{} ({} -> {}) stopped: its registered background tasks were \
1514 aborted and it was removed from NetGet's state.",
1515 params.client_id, client.protocol_name, client.remote_addr
1516 ))])),
1517 None => Ok(CallToolResult::error(vec![Content::text(format!(
1518 "Client #{} not found",
1519 params.client_id
1520 ))])),
1521 }
1522 }
1523
1524 #[tool(
1525 description = "List all clients with their protocol, remote address, status and instruction"
1526 )]
1527 async fn list_clients(&self) -> Result<CallToolResult, McpError> {
1528 let clients = self.state.app_state.get_all_clients().await;
1529
1530 if clients.is_empty() {
1531 return Ok(CallToolResult::success(vec![Content::text(
1532 "No clients currently connected.",
1533 )]));
1534 }
1535
1536 let mut clients = clients;
1537 clients.sort_by_key(|c| c.id.as_u32());
1538
1539 let mut result = String::from("## Clients\n\n");
1540 for client in &clients {
1541 result.push_str(&format!(
1542 "- **Client #{}**: {} -> {} ({})\n Instruction: {}\n Memory: {}\n\n",
1543 client.id.as_u32(),
1544 client.protocol_name,
1545 client.remote_addr,
1546 client.status,
1547 if client.instruction.is_empty() {
1548 "(none)"
1549 } else {
1550 &client.instruction
1551 },
1552 if client.memory.is_empty() {
1553 "(empty)"
1554 } else {
1555 &client.memory
1556 },
1557 ));
1558 }
1559
1560 Ok(CallToolResult::success(vec![Content::text(result)]))
1561 }
1562
1563 #[tool(description = "Get detailed status of a specific client")]
1564 async fn client_status(
1565 &self,
1566 Parameters(params): Parameters<ClientStatusParams>,
1567 ) -> Result<CallToolResult, McpError> {
1568 let client_id = crate::state::ClientId::new(params.client_id);
1569
1570 match self.state.app_state.get_client(client_id).await {
1571 Some(client) => {
1572 let mut result = format!(
1573 "## Client #{}\n\n\
1574 - **Protocol**: {}\n\
1575 - **Remote address**: {}\n\
1576 - **Status**: {}\n\
1577 - **Instruction**: {}\n\
1578 - **Memory**: {}\n",
1579 client.id.as_u32(),
1580 client.protocol_name,
1581 client.remote_addr,
1582 client.status,
1583 if client.instruction.is_empty() {
1584 "(none)"
1585 } else {
1586 &client.instruction
1587 },
1588 if client.memory.is_empty() {
1589 "(empty)"
1590 } else {
1591 &client.memory
1592 },
1593 );
1594
1595 if let Some(conn) = &client.connection {
1596 result.push_str(&format!(
1597 "- **Local address**: {}\n\
1598 - **Connected address**: {}\n\
1599 - **Bytes sent/received**: {}/{}\n\
1600 - **Packets sent/received**: {}/{}\n",
1601 conn.local_addr
1602 .map(|a| a.to_string())
1603 .unwrap_or_else(|| "(unknown)".to_string()),
1604 conn.connected_addr
1605 .map(|a| a.to_string())
1606 .unwrap_or_else(|| "(unknown)".to_string()),
1607 conn.bytes_sent,
1608 conn.bytes_received,
1609 conn.packets_sent,
1610 conn.packets_received,
1611 ));
1612 }
1613
1614 Ok(CallToolResult::success(vec![Content::text(result)]))
1615 }
1616 None => Ok(CallToolResult::error(vec![Content::text(format!(
1617 "Client #{} not found",
1618 params.client_id
1619 ))])),
1620 }
1621 }
1622
1623 #[tool(
1624 description = "Put one action on the wire through a RUNNING client, now, without waiting for an event — the dashboard's [ send ]. `action` is one of the client protocol's actions as a JSON object ({\"type\":\"send_tcp_data\",\"data\":\"hello\\n\"}); its `type` is checked against the client's action set first (get_protocol_docs lists them with parameters and examples). The client's own connection loop executes it and the result says what happened: bytes sent, executed without writing, rejected by the protocol, or disconnected. Clients whose protocol has not adopted the command channel return an error immediately rather than hanging."
1625 )]
1626 async fn send_to_client(
1627 &self,
1628 Parameters(params): Parameters<SendToClientParams>,
1629 ) -> Result<CallToolResult, McpError> {
1630 let client_id = crate::state::ClientId::new(params.client_id);
1631 let Some(client) = self.state.app_state.get_client(client_id).await else {
1632 return Ok(CallToolResult::error(vec![Content::text(format!(
1633 "Client #{} not found",
1634 params.client_id
1635 ))]));
1636 };
1637
1638 let vocabulary = match crate::mcp_stdio::control::client_vocabulary(
1639 &self.state.app_state,
1640 &client.protocol_name,
1641 ) {
1642 Ok(v) => v,
1643 Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
1644 };
1645 if let Err(e) = crate::mcp_stdio::control::check_action_types(
1646 std::slice::from_ref(¶ms.action),
1647 &vocabulary,
1648 &[],
1649 ) {
1650 return Ok(CallToolResult::error(vec![Content::text(format!(
1651 "Client #{} ({}): {}",
1652 params.client_id, client.protocol_name, e
1653 ))]));
1654 }
1655
1656 if !self.state.app_state.has_client_handle(client_id).await {
1658 return Ok(CallToolResult::error(vec![Content::text(format!(
1659 "Client #{} ({}, {}) does not accept injected actions: it is not connected, or \
1660 its protocol has not adopted the command channel. Its actions can only be \
1661 produced by its instruction or event_handlers.",
1662 params.client_id, client.protocol_name, client.status
1663 ))]));
1664 }
1665
1666 let timeout = std::time::Duration::from_secs(
1667 params
1668 .timeout_secs
1669 .unwrap_or(INJECT_TIMEOUT_DEFAULT_SECS)
1670 .clamp(1, INJECT_TIMEOUT_MAX_SECS) as u64,
1671 );
1672 match self
1673 .state
1674 .app_state
1675 .send_to_client(client_id, params.action, timeout)
1676 .await
1677 {
1678 Ok(crate::state::client_handles::ClientSendOutcome::Rejected { error }) => {
1679 Ok(CallToolResult::error(vec![Content::text(format!(
1680 "Client #{}: the protocol rejected the action: {}",
1681 params.client_id, error
1682 ))]))
1683 }
1684 Ok(outcome) => Ok(CallToolResult::success(vec![Content::text(format!(
1685 "Client #{}: {}",
1686 params.client_id,
1687 crate::mcp_stdio::control::describe_outcome(&outcome)
1688 ))])),
1689 Err(e) => {
1690 let waiting = self
1694 .state
1695 .app_state
1696 .list_intercepts()
1697 .await
1698 .into_iter()
1699 .filter(|v| {
1700 v.owner == crate::state::intercepts::InterceptOwner::Client(client_id)
1701 })
1702 .map(|v| format!("#{}", v.id))
1703 .collect::<Vec<_>>();
1704 let hint = if waiting.is_empty() {
1705 String::new()
1706 } else {
1707 format!(
1708 " — its loop is parked on request {} waiting for an answer \
1709 (list_intercepts / answer_intercept); it takes commands again once \
1710 that is answered",
1711 waiting.join(", ")
1712 )
1713 };
1714 Ok(CallToolResult::error(vec![Content::text(format!(
1715 "{e}{hint}"
1716 ))]))
1717 }
1718 }
1719 }
1720
1721 #[tool(
1722 description = "Put one action on the wire to ONE live peer of a running server, now, without waiting for the peer to speak — the dashboard's [ message ] on a peer row. `connection_id` comes from server_status, which also marks the connections that accept this (only protocols that register a peer handle do; tcp and telnet among them). `action` is one of the server protocol's actions ({\"type\":\"send_tcp_data\",\"data\":\"hi\\n\"}), checked against its action set first. Errors clearly when the protocol or connection has no peer handle, and with the protocol's own reason when it only answers requests (HTTP and the protocols on it)."
1723 )]
1724 async fn send_to_peer(
1725 &self,
1726 Parameters(params): Parameters<SendToPeerParams>,
1727 ) -> Result<CallToolResult, McpError> {
1728 let server_id = crate::state::ServerId::new(params.server_id);
1729 let Some(server) = self.state.app_state.get_server(server_id).await else {
1730 return Ok(CallToolResult::error(vec![Content::text(format!(
1731 "Server #{} not found",
1732 params.server_id
1733 ))]));
1734 };
1735 if let Some(reason) =
1736 crate::protocol::server_registry::registry().request_only_reason(&server.protocol_name)
1737 {
1738 return Ok(CallToolResult::error(vec![Content::text(format!(
1739 "Server #{} ({}) cannot message a peer: {}. It speaks only in answer to a \
1740 request — answer those through its event handlers, the model, or a `manual` \
1741 rule and answer_intercept.",
1742 params.server_id, server.protocol_name, reason
1743 ))]));
1744 }
1745 let vocabulary = match crate::mcp_stdio::control::peer_vocabulary(
1746 &self.state.app_state,
1747 &server.protocol_name,
1748 ) {
1749 Ok(v) => v,
1750 Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
1751 };
1752 if let Err(e) = crate::mcp_stdio::control::check_action_types(
1753 std::slice::from_ref(¶ms.action),
1754 &vocabulary,
1755 &[],
1756 ) {
1757 return Ok(CallToolResult::error(vec![Content::text(format!(
1758 "Server #{} ({}): {}",
1759 params.server_id, server.protocol_name, e
1760 ))]));
1761 }
1762 self.inject_into_peer(
1763 server_id,
1764 &server.protocol_name,
1765 params.connection_id,
1766 params.action,
1767 params.timeout_secs,
1768 )
1769 .await
1770 }
1771
1772 #[tool(
1773 description = "Hang up on ONE live peer of a running server from the server's side — the dashboard's [ disconnect ]. Runs the protocol's own close_connection through the connection's peer handle (half-close, and the connection is marked closed at once). Only for protocols that register a peer handle and declare close_connection; errors clearly otherwise."
1774 )]
1775 async fn disconnect_peer(
1776 &self,
1777 Parameters(params): Parameters<DisconnectPeerParams>,
1778 ) -> Result<CallToolResult, McpError> {
1779 let server_id = crate::state::ServerId::new(params.server_id);
1780 let Some(server) = self.state.app_state.get_server(server_id).await else {
1781 return Ok(CallToolResult::error(vec![Content::text(format!(
1782 "Server #{} not found",
1783 params.server_id
1784 ))]));
1785 };
1786 let declares_close = crate::mcp_stdio::control::peer_vocabulary(
1787 &self.state.app_state,
1788 &server.protocol_name,
1789 )
1790 .map(|v| v.iter().any(|a| a.name == "close_connection"))
1791 .unwrap_or(false);
1792 if !declares_close {
1793 return Ok(CallToolResult::error(vec![Content::text(format!(
1794 "Server #{} ({}) declares no close_connection action, so there is no \
1795 server-side hang-up to run for one peer. stop_server closes every connection.",
1796 params.server_id, server.protocol_name
1797 ))]));
1798 }
1799 self.inject_into_peer(
1800 server_id,
1801 &server.protocol_name,
1802 params.connection_id,
1803 serde_json::json!({"type": "close_connection"}),
1804 None,
1805 )
1806 .await
1807 }
1808
1809 #[tool(
1810 description = "List every request parked for a human answer by a `manual` event handler ({\"event_pattern\":\"*\",\"handler\":{\"type\":\"manual\",\"timeout_secs\":300}}) — the dashboard's \"waiting for YOUR answer\" rows. For each: its id, the server or client and connection it came from, the event type and its data, the actions it may be answered with (name(params), * = required), and how many seconds remain before it fails closed on its own. Answer with answer_intercept, refuse with fail_intercept."
1811 )]
1812 async fn list_intercepts(&self) -> Result<CallToolResult, McpError> {
1813 let views = self.state.app_state.list_intercepts().await;
1814 if views.is_empty() {
1815 return Ok(CallToolResult::success(vec![Content::text(
1816 "No requests are waiting for an answer.",
1817 )]));
1818 }
1819 let now = crate::state::intercepts::now_unix_ms();
1820 let mut out = format!("## Requests waiting for an answer ({})\n\n", views.len());
1821 for view in &views {
1822 let (protocol, vocabulary) =
1823 match crate::mcp_stdio::control::intercept_vocabulary(&self.state.app_state, view)
1824 .await
1825 {
1826 Ok(pair) => pair,
1827 Err(e) => (format!("unknown — {e}"), Vec::new()),
1828 };
1829 let owner = match view.owner {
1830 crate::state::intercepts::InterceptOwner::Server(id) => {
1831 format!("server #{} ({})", id.as_u32(), protocol)
1832 }
1833 crate::state::intercepts::InterceptOwner::Client(id) => {
1834 format!("client #{} ({})", id.as_u32(), protocol)
1835 }
1836 };
1837 let data = view
1838 .event_data
1839 .as_ref()
1840 .map(|d| serde_json::to_string(d).unwrap_or_else(|_| d.to_string()))
1841 .unwrap_or_else(|| "(none)".to_string());
1842 let answers: Vec<String> = vocabulary
1843 .iter()
1844 .map(crate::mcp_stdio::control::action_signature)
1845 .collect();
1846 out.push_str(&format!(
1847 "### intercept_id {}\n\
1848 - **From**: {}{}\n\
1849 - **Event**: `{}` — {}\n\
1850 - **Event data**: `{}`\n\
1851 - **Answer with**: {}\n\
1852 - **Fails closed in**: {}s (of {}s)\n\n",
1853 view.id,
1854 owner,
1855 view.connection_id
1856 .map(|c| format!(", connection #{c}"))
1857 .unwrap_or_default(),
1858 view.event_type,
1859 view.description,
1860 data,
1861 if answers.is_empty() {
1862 "(no protocol actions — answer with [] or fail it)".to_string()
1863 } else {
1864 answers.join(", ")
1865 },
1866 view.seconds_until_fail_closed(now),
1867 view.timeout_secs,
1868 ));
1869 }
1870 out.push_str(
1871 "answer_intercept {intercept_id, actions:[…]} answers (an empty array = \
1872 acknowledge and say nothing); fail_intercept {intercept_id} refuses now, and the \
1873 peer gets the protocol's fail-closed reply. get_protocol_docs has each action's \
1874 parameters and an example.",
1875 );
1876 Ok(CallToolResult::success(vec![Content::text(out)]))
1877 }
1878
1879 #[tool(
1880 description = "Answer a parked request (from list_intercepts) — the dashboard's [ Compose answer… ] / [ Answer with nothing ]. `actions` is a JSON array of action objects using only the actions list_intercepts offers for it; they run exactly as a static handler's would (same executor, same {{event.field}} interpolation). An EMPTY array is a real answer: acknowledge and say nothing — nothing is written, the connection carries on — which is distinct from letting it time out (that fails closed). Errors if the request is no longer waiting (answered, refused, timed out, or its connection closed)."
1881 )]
1882 async fn answer_intercept(
1883 &self,
1884 Parameters(params): Parameters<AnswerInterceptParams>,
1885 ) -> Result<CallToolResult, McpError> {
1886 let Some(view) = self
1887 .state
1888 .app_state
1889 .list_intercepts()
1890 .await
1891 .into_iter()
1892 .find(|v| v.id == params.intercept_id)
1893 else {
1894 return Ok(CallToolResult::error(vec![Content::text(format!(
1895 "Request #{} is not waiting (already answered, refused, timed out, or its \
1896 connection closed). list_intercepts shows what is.",
1897 params.intercept_id
1898 ))]));
1899 };
1900
1901 let (protocol, vocabulary) =
1902 match crate::mcp_stdio::control::intercept_vocabulary(&self.state.app_state, &view)
1903 .await
1904 {
1905 Ok(pair) => pair,
1906 Err(e) => return Ok(CallToolResult::error(vec![Content::text(e)])),
1907 };
1908 if let Err(e) = crate::mcp_stdio::control::check_action_types(
1909 ¶ms.actions,
1910 &vocabulary,
1911 crate::events::handler::common_action_names(),
1912 ) {
1913 return Ok(CallToolResult::error(vec![Content::text(format!(
1914 "Request #{} ({} `{}`) was NOT answered and is still waiting: {}",
1915 params.intercept_id, protocol, view.event_type, e
1916 ))]));
1917 }
1918
1919 let names: Vec<String> = params
1920 .actions
1921 .iter()
1922 .filter_map(|a| a.get("type").and_then(|t| t.as_str()).map(str::to_string))
1923 .collect();
1924 match self
1925 .state
1926 .app_state
1927 .resolve_intercept(params.intercept_id, params.actions)
1928 .await
1929 {
1930 Ok(()) if names.is_empty() => {
1931 Ok(CallToolResult::success(vec![Content::text(format!(
1932 "Answered request #{} with nothing: acknowledged, no reply written, the \
1933 connection carries on.",
1934 params.intercept_id
1935 ))]))
1936 }
1937 Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
1938 "Answered request #{} with {}. The waiting connection executes them now; \
1939 list_access_logs shows the outcome.",
1940 params.intercept_id,
1941 names.join(", ")
1942 ))])),
1943 Err(e) => Ok(CallToolResult::error(vec![Content::text(e)])),
1944 }
1945 }
1946
1947 #[tool(
1948 description = "Refuse a parked request (from list_intercepts) now instead of letting it wait out its timeout — the dashboard's [ Fail closed ]. The peer gets exactly what it would get if the model had failed: the protocol's own fail-closed reply (for tcp, a half-close; for http, an error status), never an invented success. There is no separate 'dismiss': refusing a parked request IS failing it closed."
1949 )]
1950 async fn fail_intercept(
1951 &self,
1952 Parameters(params): Parameters<FailInterceptParams>,
1953 ) -> Result<CallToolResult, McpError> {
1954 if self
1955 .state
1956 .app_state
1957 .dismiss_intercept(params.intercept_id)
1958 .await
1959 {
1960 Ok(CallToolResult::success(vec![Content::text(format!(
1961 "Refused request #{}: the peer gets the protocol's fail-closed reply.",
1962 params.intercept_id
1963 ))]))
1964 } else {
1965 Ok(CallToolResult::error(vec![Content::text(format!(
1966 "Request #{} is not waiting (already answered, refused or timed out).",
1967 params.intercept_id
1968 ))]))
1969 }
1970 }
1971
1972 #[tool(
1973 description = "Agent-LLM mode only (netget --llm-agent): fetch the next queued LLM request for a protocol server so YOU (the calling agent) can answer it in place of a model. Optionally long-poll with wait_seconds. Returns the request id, the prompt (server instruction + the triggering event), and the actions you may use. Reply by calling answer_llm_request with that id and a JSON array of actions. Returns '(no pending requests)' if none arrive within the wait."
1974 )]
1975 async fn get_next_llm_request(
1976 &self,
1977 Parameters(params): Parameters<GetNextLlmRequestParams>,
1978 ) -> Result<CallToolResult, McpError> {
1979 let Some(queue) = self.state.agent_queue.as_ref() else {
1980 return Ok(CallToolResult::error(vec![Content::text(
1981 "Agent-LLM mode is not enabled. Start netget with --llm-agent to queue LLM requests for the agent.",
1982 )]));
1983 };
1984
1985 let wait = params.wait_seconds.unwrap_or(0).min(120);
1987 let req = queue
1988 .wait_and_claim(std::time::Duration::from_secs(wait as u64))
1989 .await;
1990
1991 let Some(req) = req else {
1992 return Ok(CallToolResult::success(vec![Content::text(
1993 "(no pending requests)",
1994 )]));
1995 };
1996
1997 let mut prompt = String::new();
1999 for m in &req.messages {
2000 prompt.push_str(&format!("### {}\n{}\n\n", m.role, m.content));
2001 }
2002
2003 let actions_json =
2004 serde_json::to_string_pretty(&req.tools).unwrap_or_else(|_| "[]".to_string());
2005
2006 let now_ms = crate::utils::clock::SystemTime::now()
2007 .duration_since(crate::utils::clock::UNIX_EPOCH)
2008 .map(|d| d.as_millis() as u64)
2009 .unwrap_or(0);
2010 let age = now_ms.saturating_sub(req.created_unix_ms) / 1000;
2011
2012 let result = format!(
2013 "## LLM request #{}\n\n\
2014 - **Queued**: {}s ago\n\n\
2015 ### Prompt\n{}\n\
2016 ### Available actions (tool schemas)\n```json\n{}\n```\n\n\
2017 To answer, call `answer_llm_request` with:\n\
2018 ```json\n{{ \"request_id\": {}, \"actions\": [ /* one or more action objects */ ] }}\n```\n\
2019 Each action object needs a `type` field naming the action, plus its parameters. \
2020 See `get_access_log` for worked examples of the action JSON a protocol expects.",
2021 req.id, age, prompt, actions_json, req.id,
2022 );
2023
2024 Ok(CallToolResult::success(vec![Content::text(result)]))
2025 }
2026
2027 #[tool(
2028 description = "Agent-LLM mode only: answer a queued LLM request (from get_next_llm_request). `actions` is a JSON array of NetGet action objects — each with a `type` field and its parameters — that the protocol server will execute (e.g. [{\"type\":\"send_tcp_data\",\"data\":\"...\"}]). This unblocks the waiting connection."
2029 )]
2030 async fn answer_llm_request(
2031 &self,
2032 Parameters(params): Parameters<AnswerLlmRequestParams>,
2033 ) -> Result<CallToolResult, McpError> {
2034 let Some(queue) = self.state.agent_queue.as_ref() else {
2035 return Ok(CallToolResult::error(vec![Content::text(
2036 "Agent-LLM mode is not enabled. Start netget with --llm-agent.",
2037 )]));
2038 };
2039
2040 match queue.answer(params.request_id, params.actions) {
2041 Ok(()) => Ok(CallToolResult::success(vec![Content::text(format!(
2042 "Answered LLM request #{}.",
2043 params.request_id
2044 ))])),
2045 Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
2046 }
2047 }
2048
2049 #[tool(
2050 description = "Agent-LLM mode only: list outstanding queued LLM requests (pending and claimed-but-unanswered) for monitoring."
2051 )]
2052 async fn list_llm_requests(&self) -> Result<CallToolResult, McpError> {
2053 let Some(queue) = self.state.agent_queue.as_ref() else {
2054 return Ok(CallToolResult::error(vec![Content::text(
2055 "Agent-LLM mode is not enabled. Start netget with --llm-agent.",
2056 )]));
2057 };
2058
2059 let pending = queue.list();
2060 if pending.is_empty() {
2061 return Ok(CallToolResult::success(vec![Content::text(
2062 "No outstanding LLM requests.",
2063 )]));
2064 }
2065
2066 let now_ms = crate::utils::clock::SystemTime::now()
2067 .duration_since(crate::utils::clock::UNIX_EPOCH)
2068 .map(|d| d.as_millis() as u64)
2069 .unwrap_or(0);
2070
2071 let mut out = String::from("## Outstanding LLM requests\n\n");
2072 for r in &pending {
2073 let age = now_ms.saturating_sub(r.created_unix_ms) / 1000;
2074 out.push_str(&format!(
2075 "- **#{}** — {} — queued {}s ago\n",
2076 r.id,
2077 if r.claimed { "claimed" } else { "pending" },
2078 age
2079 ));
2080 }
2081 Ok(CallToolResult::success(vec![Content::text(out)]))
2082 }
2083}
2084
2085impl NetGetMcpService {
2086 async fn inject_into_peer(
2090 &self,
2091 server_id: crate::state::ServerId,
2092 protocol_name: &str,
2093 connection_id: u32,
2094 action: serde_json::Value,
2095 timeout_secs: Option<u32>,
2096 ) -> Result<CallToolResult, McpError> {
2097 if !self
2098 .state
2099 .app_state
2100 .has_peer_handle(server_id, connection_id)
2101 .await
2102 {
2103 return Ok(CallToolResult::error(vec![Content::text(format!(
2104 "Connection #{} on server #{} ({}) does not accept injected actions: either \
2105 it is not a live connection of that server (server_status lists them), or \
2106 the {} protocol does not register a peer handle for its connections.",
2107 connection_id,
2108 server_id.as_u32(),
2109 protocol_name,
2110 protocol_name
2111 ))]));
2112 }
2113 let timeout = std::time::Duration::from_secs(
2114 timeout_secs
2115 .unwrap_or(INJECT_TIMEOUT_DEFAULT_SECS)
2116 .clamp(1, INJECT_TIMEOUT_MAX_SECS) as u64,
2117 );
2118 match self
2119 .state
2120 .app_state
2121 .send_to_peer(server_id, connection_id, action, timeout)
2122 .await
2123 {
2124 Ok(crate::state::client_handles::ClientSendOutcome::Rejected { error }) => {
2125 Ok(CallToolResult::error(vec![Content::text(format!(
2126 "Connection #{} on server #{}: the protocol rejected the action: {}",
2127 connection_id,
2128 server_id.as_u32(),
2129 error
2130 ))]))
2131 }
2132 Ok(outcome) => Ok(CallToolResult::success(vec![Content::text(format!(
2133 "Connection #{} on server #{}: {}",
2134 connection_id,
2135 server_id.as_u32(),
2136 crate::mcp_stdio::control::describe_outcome(&outcome)
2137 ))])),
2138 Err(e) => Ok(CallToolResult::error(vec![Content::text(e.to_string())])),
2139 }
2140 }
2141
2142 pub fn app_state(&self) -> AppState {
2153 self.state.app_state.clone()
2154 }
2155}
2156
2157#[tool_handler]
2158impl ServerHandler for NetGetMcpService {
2159 fn get_info(&self) -> <RoleServer as rmcp::service::ServiceRole>::Info {
2160 InitializeResult::new(ServerCapabilities::builder().enable_tools().build())
2161 .with_server_info(Implementation::new("netget", env!("CARGO_PKG_VERSION")))
2162 .with_instructions(
2163 "NetGet - LLM-controlled network protocol server. \
2164 Use list_protocols to discover available protocols, then start_server to \
2165 create one. \
2166 IMPORTANT — choose the handling mode deliberately: for deterministic \
2167 behavior (echo servers, fixed/canned responses, simple routing, high \
2168 throughput) pass `event_handlers` with a script or static handler, which \
2169 run in-process with NO LLM call (instant, free, reproducible). Reserve the \
2170 natural-language `instruction` (the LLM fallback, one model call per \
2171 request) for responses that genuinely need reasoning or vary \
2172 unpredictably. Use get_protocol_docs to see a protocol's event ids and \
2173 actions before writing a handler.\n\n\
2174 CLIENTS: start_client / list_clients / client_status / stop_client are \
2175 the mirror of the server tools, for connecting OUT to a remote server \
2176 instead of listening. They take the same instruction and event_handlers \
2177 arguments plus a remote_addr. stop_client aborts the client's background \
2178 tasks, releasing its socket and stopping further LLM calls.\n\n\
2179 DRIVING BY HAND: send_to_client puts one of a client's actions on the wire \
2180 now; send_to_peer / disconnect_peer do the same for one live connection of a \
2181 server (server_status lists connection ids). A `manual` event handler parks \
2182 each matching event for YOU: list_intercepts shows them, answer_intercept \
2183 answers (an empty array acknowledges without replying), fail_intercept \
2184 refuses with the protocol's fail-closed reply.\n\n\
2185 AGENT-LLM MODE (when netget was started with --llm-agent): there is no \
2186 model — YOU answer the LLM calls. When a server needs a reasoned \
2187 response it queues a request; fetch it with get_next_llm_request \
2188 (optionally long-poll via wait_seconds), then reply with \
2189 answer_llm_request(request_id, actions). If --llm-agent-pipe was set, a \
2190 line with the new request id is written to that FIFO on each enqueue so \
2191 you can block-read it instead of polling. Unanswered requests error out \
2192 after the configured timeout. list_llm_requests shows what is outstanding.",
2193 )
2194 }
2195
2196 fn initialize(
2197 &self,
2198 request: InitializeRequestParams,
2199 context: RequestContext<RoleServer>,
2200 ) -> impl std::future::Future<Output = Result<InitializeResult, McpError>> + Send + '_ {
2201 async move {
2202 info!(
2203 "MCP client '{}' v{} initialized",
2204 request.client_info.name, request.client_info.version,
2205 );
2206
2207 if context.peer.peer_info().is_none() {
2209 context.peer.set_peer_info(request);
2210 }
2211
2212 Ok(self.get_info())
2213 }
2214 }
2215}
2216
2217fn parse_scheduled_tasks(
2223 tasks: Option<Vec<serde_json::Value>>,
2224) -> Result<Option<Vec<crate::llm::actions::common::ServerTaskDefinition>>, String> {
2225 let Some(tasks) = tasks else {
2226 return Ok(None);
2227 };
2228 if tasks.is_empty() {
2229 return Ok(None);
2230 }
2231 let mut parsed = Vec::with_capacity(tasks.len());
2232 for (i, task) in tasks.into_iter().enumerate() {
2233 match serde_json::from_value::<crate::llm::actions::common::ServerTaskDefinition>(task) {
2234 Ok(def) => parsed.push(def),
2235 Err(e) => {
2236 return Err(format!(
2237 "entry {}: {}. Each entry needs \"task_id\" (string), \"recurring\" (boolean) \
2238 and \"instruction\" (string); recurring tasks also take \"interval_secs\", \
2239 one-shot tasks \"delay_secs\".",
2240 i, e
2241 ));
2242 }
2243 }
2244 }
2245 Ok(Some(parsed))
2246}
2247
2248fn summarize_request(request: &serde_json::Value) -> String {
2250 let method = request.get("method").and_then(|v| v.as_str());
2252 let path = request.get("path").and_then(|v| v.as_str());
2253 if let (Some(m), Some(p)) = (method, path) {
2254 return format!("{} {}", m, p);
2255 }
2256 if let Some(p) = path {
2257 return p.to_string();
2258 }
2259 let compact = request.to_string();
2261 crate::utils::truncate_with_suffix(&compact, 79, "…")
2262}
2263
2264fn summarize_response(response: &[serde_json::Value]) -> String {
2266 if response.is_empty() {
2267 return "(no response)".to_string();
2268 }
2269 let parts: Vec<String> = response
2270 .iter()
2271 .map(|a| {
2272 let ty = a.get("type").and_then(|v| v.as_str()).unwrap_or("action");
2273 match a
2275 .get("status_code")
2276 .or_else(|| a.get("status"))
2277 .and_then(|v| v.as_u64())
2278 {
2279 Some(code) => format!("{}({})", ty, code),
2280 None => ty.to_string(),
2281 }
2282 })
2283 .collect();
2284 parts.join(", ")
2285}