Skip to main content

perspt_agent/external_tools/
mod.rs

1//! Shared governed MCP runtime for agent and interactive chat lifecycles.
2//!
3//! Discovery is lazy, remote descriptions/results are observations, and only
4//! local policy can classify effects and footprints. Constructing one runtime
5//! never starts a server; `discover_server` is the first lifecycle action.
6
7#![allow(deprecated)]
8
9pub mod chat;
10mod client;
11
12use std::collections::{BTreeMap, VecDeque};
13use std::sync::Arc;
14
15use anyhow::{Context, Result};
16use serde::{Deserialize, Serialize};
17
18use client::McpConnection;
19pub use client::{
20    DecliningMcpElicitationProvider, McpClientServices, McpElicitationAction, McpElicitationBroker,
21    McpElicitationProvider, McpPendingElicitation, McpSamplingProvider, McpServerEvent,
22    ModelTransportSamplingProvider, MCP_PROTOCOL_VERSION,
23};
24use perspt_core::{Config, ExternalToolConfig, ExternalToolMode, ExternalToolPolicy};
25use perspt_sdk::{
26    admit_external_tool, Capability, EffectKind, ExternalToolDeclaration, RiskClass, ToolEntry,
27};
28
29#[derive(Debug, Clone)]
30struct McpRemoteTool {
31    name: String,
32    description: String,
33    input_schema: serde_json::Value,
34}
35
36#[derive(Debug, Clone)]
37enum McpBinding {
38    Tool(String),
39    ResourcesList,
40    ResourceTemplatesList,
41    ResourceRead,
42    PromptsList,
43    PromptGet,
44    Complete,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum ExternalConnectionState {
50    Configured,
51    Connected,
52    Discovered,
53    Closed,
54}
55
56#[derive(Debug, Clone, Serialize, Deserialize)]
57#[serde(tag = "event", rename_all = "snake_case")]
58pub enum ExternalToolEvent {
59    Connection {
60        server_id: String,
61        state: ExternalConnectionState,
62    },
63    Admission {
64        server_id: String,
65        tool: String,
66        admitted: bool,
67        reason: Option<String>,
68    },
69    Proposal {
70        server_id: String,
71        tool: String,
72    },
73    Result {
74        server_id: String,
75        tool: String,
76        is_error: bool,
77    },
78    ReconciliationRequired {
79        server_id: String,
80        tool: String,
81        reason: String,
82    },
83}
84
85pub trait ExternalToolObserver: Send + Sync {
86    fn record(&self, event: &ExternalToolEvent) -> Result<()>;
87}
88
89#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
90pub struct ExternalToolResult {
91    pub content: serde_json::Value,
92    pub is_error: bool,
93    pub replayed: bool,
94}
95
96/// A locally rejected remote declaration. The server's text is diagnostic
97/// data only; it never changes the trusted effect or footprint policy.
98#[derive(Debug, Clone, PartialEq, Eq)]
99pub struct ExternalToolRejection {
100    pub server_id: String,
101    pub remote_tool: String,
102    pub reason: String,
103}
104
105struct ServerState {
106    config: ExternalToolConfig,
107    state: ExternalConnectionState,
108    client: Option<McpConnection>,
109    calls: BTreeMap<String, McpBinding>,
110}
111
112impl std::fmt::Debug for ServerState {
113    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
114        formatter
115            .debug_struct("ServerState")
116            .field("id", &self.config.id)
117            .field("state", &self.state)
118            .field("connected", &self.client.is_some())
119            .field("tool_count", &self.calls.len())
120            .finish()
121    }
122}
123
124/// A single role-specific external-tool lifecycle. Agent and chat construct
125/// separate instances even when they select the same server configuration.
126pub struct ExternalToolRuntime {
127    mode: ExternalToolMode,
128    capabilities: Vec<Capability>,
129    servers: BTreeMap<String, ServerState>,
130    entries: BTreeMap<String, ToolEntry>,
131    rejections: Vec<ExternalToolRejection>,
132    observer: Option<Arc<dyn ExternalToolObserver>>,
133    replay_results: BTreeMap<String, VecDeque<ExternalToolResult>>,
134    replay_only: bool,
135    client_services: McpClientServices,
136}
137
138impl std::fmt::Debug for ExternalToolRuntime {
139    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140        formatter
141            .debug_struct("ExternalToolRuntime")
142            .field("mode", &self.mode)
143            .field("servers", &self.servers)
144            .field("entry_count", &self.entries.len())
145            .field("replay_only", &self.replay_only)
146            .finish_non_exhaustive()
147    }
148}
149
150impl ExternalToolRuntime {
151    pub fn from_config(
152        config: &Config,
153        mode: ExternalToolMode,
154        capabilities: Vec<Capability>,
155    ) -> Result<Self> {
156        config.validate()?;
157        let servers = config
158            .external_tools
159            .iter()
160            .filter(|server| server.supports(mode))
161            .map(|server| {
162                (
163                    server.id.clone(),
164                    ServerState {
165                        config: server.clone(),
166                        state: ExternalConnectionState::Configured,
167                        client: None,
168                        calls: BTreeMap::new(),
169                    },
170                )
171            })
172            .collect();
173        Ok(Self {
174            mode,
175            capabilities,
176            servers,
177            entries: BTreeMap::new(),
178            rejections: Vec::new(),
179            observer: None,
180            replay_results: BTreeMap::new(),
181            replay_only: false,
182            client_services: McpClientServices::default(),
183        })
184    }
185
186    /// Build a replay lifecycle from admitted entries and recorded results.
187    /// It has no server configuration and therefore cannot reconnect.
188    pub fn replay(
189        mode: ExternalToolMode,
190        entries: Vec<ToolEntry>,
191        results: BTreeMap<String, VecDeque<ExternalToolResult>>,
192    ) -> Self {
193        Self {
194            mode,
195            capabilities: Vec::new(),
196            servers: BTreeMap::new(),
197            entries: entries
198                .into_iter()
199                .map(|entry| (entry.name.clone(), entry))
200                .collect(),
201            rejections: Vec::new(),
202            observer: None,
203            replay_results: results,
204            replay_only: true,
205            client_services: McpClientServices::default(),
206        }
207    }
208
209    /// Install product-owned handlers for authority-bearing server requests.
210    /// Configured sampling or elicitation fails connection when its handler is
211    /// absent, so capability advertisement can never outrun the product UI.
212    pub fn set_client_services(&mut self, services: McpClientServices) {
213        self.client_services = services;
214    }
215
216    /// Replace the admission capabilities (the composition root supplies
217    /// the session's derived grant surface before discovery).
218    pub fn set_capabilities(&mut self, capabilities: Vec<Capability>) {
219        self.capabilities = capabilities;
220    }
221
222    pub fn with_observer(mut self, observer: Arc<dyn ExternalToolObserver>) -> Self {
223        self.observer = Some(observer);
224        self
225    }
226
227    pub fn server_ids(&self) -> Vec<&str> {
228        self.servers.keys().map(String::as_str).collect()
229    }
230
231    pub fn state(&self, server_id: &str) -> Option<ExternalConnectionState> {
232        self.servers
233            .get(server_id)
234            .map(|server| server.state.clone())
235    }
236
237    pub fn admitted_entries(&self) -> Vec<ToolEntry> {
238        self.entries.values().cloned().collect()
239    }
240
241    pub fn admission_rejections(&self, server_id: &str) -> Vec<ExternalToolRejection> {
242        self.rejections
243            .iter()
244            .filter(|rejection| rejection.server_id == server_id)
245            .cloned()
246            .collect()
247    }
248
249    /// Drain progress, logging, resource, catalog, task, and subscription
250    /// notifications received since the previous call.
251    pub fn drain_server_events(&mut self, server_id: &str) -> Result<Vec<McpServerEvent>> {
252        let server = self
253            .servers
254            .get_mut(server_id)
255            .with_context(|| format!("unknown external tool server {server_id:?}"))?;
256        Ok(server
257            .client
258            .as_mut()
259            .map(McpConnection::drain_events)
260            .unwrap_or_default())
261    }
262
263    /// Captured rolling stderr tail for a stdio server.
264    pub fn stderr_tail(&self, server_id: &str) -> Result<Vec<u8>> {
265        let server = self
266            .servers
267            .get(server_id)
268            .with_context(|| format!("unknown external tool server {server_id:?}"))?;
269        Ok(server
270            .client
271            .as_ref()
272            .map(McpConnection::stderr_tail)
273            .unwrap_or_default())
274    }
275
276    /// Set a server's MCP logging threshold when it advertises logging.
277    pub async fn set_log_level(&self, server_id: &str, level: &str) -> Result<()> {
278        let level = match level.to_ascii_lowercase().as_str() {
279            "debug" => rmcp::model::LoggingLevel::Debug,
280            "info" => rmcp::model::LoggingLevel::Info,
281            "notice" => rmcp::model::LoggingLevel::Notice,
282            "warning" => rmcp::model::LoggingLevel::Warning,
283            "error" => rmcp::model::LoggingLevel::Error,
284            "critical" => rmcp::model::LoggingLevel::Critical,
285            "alert" => rmcp::model::LoggingLevel::Alert,
286            "emergency" => rmcp::model::LoggingLevel::Emergency,
287            _ => anyhow::bail!("unknown MCP logging level {level:?}"),
288        };
289        let client = self
290            .servers
291            .get(server_id)
292            .and_then(|server| server.client.as_ref())
293            .context("MCP server is not connected")?;
294        client.set_log_level(level).await
295    }
296
297    pub async fn discover_server(&mut self, server_id: &str) -> Result<Vec<ToolEntry>> {
298        anyhow::ensure!(
299            !self.replay_only,
300            "replay never reconnects to an MCP server"
301        );
302        if self
303            .servers
304            .get(server_id)
305            .is_some_and(|server| !server.calls.is_empty())
306        {
307            return Ok(self.entries_for_server(server_id));
308        }
309        self.connect(server_id).await?;
310        self.refresh_server(server_id).await
311    }
312
313    /// Re-list and re-admit one connected server's catalog after an MCP
314    /// list-change notification. Old bindings are removed before replacement,
315    /// so a deleted remote tool can never remain callable.
316    pub async fn refresh_server(&mut self, server_id: &str) -> Result<Vec<ToolEntry>> {
317        self.connect(server_id).await?;
318        self.remove_server_entries(server_id);
319        let remote_tools = self.list_all_tools(server_id).await?;
320        let mut admitted = self.admit_tools(server_id, remote_tools)?;
321        admitted.extend(self.admit_mcp_ops(server_id)?);
322        self.observe(ExternalToolEvent::Connection {
323            server_id: server_id.to_string(),
324            state: ExternalConnectionState::Discovered,
325        })?;
326        Ok(admitted)
327    }
328
329    /// Drain notifications and refresh any changed tool catalogs. All events
330    /// are returned to the product for display or durable recording.
331    pub async fn sync_server_events(&mut self) -> Result<Vec<(String, McpServerEvent)>> {
332        let ids: Vec<String> = self.servers.keys().cloned().collect();
333        let mut all = Vec::new();
334        for server_id in ids {
335            let events = self.drain_server_events(&server_id)?;
336            let tools_changed = events
337                .iter()
338                .any(|event| matches!(event, McpServerEvent::ToolsChanged));
339            all.extend(events.into_iter().map(|event| (server_id.clone(), event)));
340            if tools_changed {
341                self.refresh_server(&server_id).await?;
342            }
343        }
344        Ok(all)
345    }
346
347    pub async fn call(
348        &mut self,
349        namespaced_tool: &str,
350        arguments: serde_json::Value,
351    ) -> Result<ExternalToolResult> {
352        let entry = self
353            .entries
354            .get(namespaced_tool)
355            .cloned()
356            .with_context(|| format!("external tool {namespaced_tool:?} was not admitted"))?;
357        entry.validate_arguments(&arguments)?;
358        let replay_key = replay_key(namespaced_tool, &arguments)?;
359        if self.replay_only {
360            return self.consume_replay(&replay_key);
361        }
362        let (server_id, remote_name) = self.resolve_call(namespaced_tool)?;
363        self.observe(ExternalToolEvent::Proposal {
364            server_id: server_id.clone(),
365            tool: namespaced_tool.to_string(),
366        })?;
367        let result = self.call_live(&server_id, &remote_name, arguments).await;
368        match result {
369            Ok(result) => {
370                self.observe(ExternalToolEvent::Result {
371                    server_id,
372                    tool: namespaced_tool.to_string(),
373                    is_error: result.is_error,
374                })?;
375                Ok(result)
376            }
377            Err(error) => {
378                self.observe(ExternalToolEvent::ReconciliationRequired {
379                    server_id,
380                    tool: namespaced_tool.to_string(),
381                    reason: error.to_string(),
382                })?;
383                Err(error.context(
384                    "external completion is uncertain; explicit reconciliation is required",
385                ))
386            }
387        }
388    }
389
390    pub async fn shutdown(&mut self) -> Result<()> {
391        let ids: Vec<String> = self.servers.keys().cloned().collect();
392        for id in ids {
393            let server = self.servers.get_mut(&id).expect("server id collected");
394            if let Some(client) = server.client.as_mut() {
395                client.shutdown().await?;
396            }
397            server.client = None;
398            server.state = ExternalConnectionState::Closed;
399            self.observe(ExternalToolEvent::Connection {
400                server_id: id,
401                state: ExternalConnectionState::Closed,
402            })?;
403        }
404        Ok(())
405    }
406
407    async fn connect(&mut self, server_id: &str) -> Result<()> {
408        let config = self
409            .servers
410            .get(server_id)
411            .with_context(|| format!("unknown external tool server {server_id:?}"))?
412            .config
413            .clone();
414        if self
415            .servers
416            .get(server_id)
417            .is_some_and(|server| server.client.is_some())
418        {
419            return Ok(());
420        }
421        let client = McpConnection::connect(&config, self.client_services.clone()).await?;
422        let server = self.servers.get_mut(server_id).expect("server checked");
423        server.client = Some(client);
424        server.state = ExternalConnectionState::Connected;
425        self.observe(ExternalToolEvent::Connection {
426            server_id: server_id.to_string(),
427            state: ExternalConnectionState::Connected,
428        })
429    }
430
431    async fn list_all_tools(&mut self, server_id: &str) -> Result<Vec<McpRemoteTool>> {
432        let server = self
433            .servers
434            .get_mut(server_id)
435            .context("server disappeared")?;
436        let client = server.client.as_ref().context("server is not connected")?;
437        let tools = client.list_tools().await?;
438        anyhow::ensure!(
439            tools.len() <= 10_000,
440            "MCP server advertised too many tools"
441        );
442        Ok(tools
443            .into_iter()
444            .map(|tool| McpRemoteTool {
445                name: tool.name.into_owned(),
446                description: tool
447                    .description
448                    .map(|value| value.into_owned())
449                    .unwrap_or_default(),
450                input_schema: serde_json::Value::Object(tool.input_schema.as_ref().clone()),
451            })
452            .collect())
453    }
454
455    fn admit_tools(
456        &mut self,
457        server_id: &str,
458        remote_tools: Vec<McpRemoteTool>,
459    ) -> Result<Vec<ToolEntry>> {
460        self.rejections
461            .retain(|rejection| rejection.server_id != server_id);
462        let policies = self
463            .servers
464            .get(server_id)
465            .context("server disappeared")?
466            .config
467            .tools
468            .clone();
469        let mut admitted = Vec::new();
470        for remote in remote_tools {
471            if remote.name.starts_with("_perspt_") {
472                let reason = "remote tool name is reserved for Perspt MCP operations".to_string();
473                self.rejections.push(ExternalToolRejection {
474                    server_id: server_id.to_string(),
475                    remote_tool: remote.name.clone(),
476                    reason: reason.clone(),
477                });
478                self.observe_admission(
479                    server_id,
480                    &namespace(server_id, &remote.name)?,
481                    false,
482                    Some(reason),
483                )?;
484                continue;
485            }
486            let policy = policies.get(&remote.name).cloned().unwrap_or_default();
487            match self.admit_one(server_id, &remote, &policy) {
488                Ok(entry) => {
489                    self.store_entry(server_id, remote.name, entry.clone())?;
490                    self.observe_admission(server_id, &entry.name, true, None)?;
491                    admitted.push(entry);
492                }
493                Err(error) => {
494                    let reason = error.to_string();
495                    self.rejections.push(ExternalToolRejection {
496                        server_id: server_id.to_string(),
497                        remote_tool: remote.name.clone(),
498                        reason: reason.clone(),
499                    });
500                    let name = namespace(server_id, &remote.name)
501                        .unwrap_or_else(|_| format!("mcp.{server_id}.<invalid-name>"));
502                    self.observe_admission(server_id, &name, false, Some(reason))?;
503                }
504            }
505        }
506        self.servers
507            .get_mut(server_id)
508            .expect("server checked")
509            .state = ExternalConnectionState::Discovered;
510        Ok(admitted)
511    }
512
513    fn remove_server_entries(&mut self, server_id: &str) {
514        let names = self
515            .servers
516            .get_mut(server_id)
517            .map(|server| {
518                std::mem::take(&mut server.calls)
519                    .into_keys()
520                    .collect::<Vec<_>>()
521            })
522            .unwrap_or_default();
523        for name in names {
524            self.entries.remove(&name);
525        }
526    }
527
528    fn admit_mcp_ops(&mut self, server_id: &str) -> Result<Vec<ToolEntry>> {
529        let capabilities = self
530            .servers
531            .get(server_id)
532            .and_then(|server| server.client.as_ref())
533            .and_then(McpConnection::peer_info)
534            .context("MCP discover omitted server capabilities")?
535            .capabilities
536            .clone();
537        let mut admitted = Vec::new();
538        for (remote_name, description, effect, schema, binding) in mcp_ops(&capabilities) {
539            let entry = admit_external_tool(
540                server_id,
541                ExternalToolDeclaration {
542                    name: namespace(server_id, remote_name)?,
543                    description: description.to_string(),
544                    effect,
545                    risk: RiskClass::Low,
546                    schema,
547                    footprint: Some(perspt_sdk::FootprintSpec::default()),
548                },
549                &self.capabilities,
550            );
551            if let Ok(entry) = entry {
552                self.store_binding(server_id, binding, entry.clone())?;
553                admitted.push(entry);
554            }
555        }
556        Ok(admitted)
557    }
558
559    fn admit_one(
560        &self,
561        server_id: &str,
562        remote: &McpRemoteTool,
563        policy: &ExternalToolPolicy,
564    ) -> Result<ToolEntry> {
565        let name = namespace(server_id, &remote.name)?;
566        let declaration = ExternalToolDeclaration {
567            name,
568            description: format!("[untrusted MCP description] {}", remote.description),
569            effect: policy.effect.unwrap_or(EffectKind::RunShell),
570            risk: policy.risk.unwrap_or(RiskClass::High),
571            schema: remote.input_schema.clone(),
572            footprint: policy.footprint.clone(),
573        };
574        let mut entry = admit_external_tool(server_id, declaration, &self.capabilities)
575            .map_err(|error| anyhow::anyhow!(error.to_string()))?;
576        entry.proposal_bindings = policy.proposal_bindings.clone();
577        entry
578            .validate()
579            .map_err(|error| anyhow::anyhow!(error.to_string()))?;
580        Ok(entry)
581    }
582
583    fn store_entry(&mut self, server_id: &str, remote: String, entry: ToolEntry) -> Result<()> {
584        self.store_binding(server_id, McpBinding::Tool(remote), entry)
585    }
586
587    fn store_binding(
588        &mut self,
589        server_id: &str,
590        binding: McpBinding,
591        entry: ToolEntry,
592    ) -> Result<()> {
593        anyhow::ensure!(
594            self.entries
595                .insert(entry.name.clone(), entry.clone())
596                .is_none(),
597            "duplicate namespaced MCP tool {:?}",
598            entry.name
599        );
600        self.servers
601            .get_mut(server_id)
602            .context("server disappeared")?
603            .calls
604            .insert(entry.name, binding);
605        Ok(())
606    }
607
608    fn observe_admission(
609        &self,
610        server_id: &str,
611        tool: &str,
612        admitted: bool,
613        reason: Option<String>,
614    ) -> Result<()> {
615        self.observe(ExternalToolEvent::Admission {
616            server_id: server_id.to_string(),
617            tool: tool.to_string(),
618            admitted,
619            reason,
620        })
621    }
622
623    fn entries_for_server(&self, server_id: &str) -> Vec<ToolEntry> {
624        let prefix = format!("mcp.{server_id}.");
625        self.entries
626            .values()
627            .filter(|entry| entry.name.starts_with(&prefix))
628            .cloned()
629            .collect()
630    }
631
632    fn resolve_call(&self, tool: &str) -> Result<(String, McpBinding)> {
633        self.servers
634            .iter()
635            .find_map(|(server_id, server)| {
636                server
637                    .calls
638                    .get(tool)
639                    .map(|binding| (server_id.clone(), binding.clone()))
640            })
641            .with_context(|| format!("no live MCP binding for {tool:?}"))
642    }
643
644    async fn call_live(
645        &mut self,
646        server_id: &str,
647        binding: &McpBinding,
648        arguments: serde_json::Value,
649    ) -> Result<ExternalToolResult> {
650        let server = self
651            .servers
652            .get_mut(server_id)
653            .context("server disappeared")?;
654        let client = server.client.as_ref().context("server is not connected")?;
655        let arguments = arguments
656            .as_object()
657            .cloned()
658            .context("MCP tool arguments must be a JSON object")?;
659        let (content, is_error) = call_binding(client, binding, arguments).await?;
660        Ok(ExternalToolResult {
661            content,
662            is_error,
663            replayed: false,
664        })
665    }
666
667    fn consume_replay(&mut self, key: &str) -> Result<ExternalToolResult> {
668        let mut result = self
669            .replay_results
670            .get_mut(key)
671            .and_then(VecDeque::pop_front)
672            .with_context(|| format!("no recorded MCP observation for replay key {key}"))?;
673        result.replayed = true;
674        Ok(result)
675    }
676
677    fn observe(&self, event: ExternalToolEvent) -> Result<()> {
678        match &self.observer {
679            Some(observer) => observer.record(&event),
680            None => Ok(()),
681        }
682    }
683}
684
685type McpOp = (
686    &'static str,
687    &'static str,
688    EffectKind,
689    serde_json::Value,
690    McpBinding,
691);
692
693fn mcp_ops(capabilities: &rmcp::model::ServerCapabilities) -> Vec<McpOp> {
694    let mut ops = Vec::new();
695    if capabilities.resources.is_some() {
696        ops.extend([
697            (
698                "_perspt_resources_list",
699                "List resources exposed by this MCP server.",
700                EffectKind::List,
701                empty_object_schema(),
702                McpBinding::ResourcesList,
703            ),
704            (
705                "_perspt_resource_templates_list",
706                "List URI templates for resources exposed by this MCP server.",
707                EffectKind::List,
708                empty_object_schema(),
709                McpBinding::ResourceTemplatesList,
710            ),
711            (
712                "_perspt_resource_read",
713                "Read one exact MCP resource URI as untrusted data.",
714                EffectKind::DataRead,
715                string_schema(&[("uri", "Exact resource URI", true)]),
716                McpBinding::ResourceRead,
717            ),
718        ]);
719    }
720    if capabilities.prompts.is_some() {
721        ops.extend([
722            (
723                "_perspt_prompts_list",
724                "List prompt templates exposed by this MCP server.",
725                EffectKind::List,
726                empty_object_schema(),
727                McpBinding::PromptsList,
728            ),
729            (
730                "_perspt_prompt_get",
731                "Resolve an MCP prompt as untrusted input.",
732                EffectKind::DataRead,
733                prompt_get_schema(),
734                McpBinding::PromptGet,
735            ),
736        ]);
737    }
738    if capabilities.completions.is_some() {
739        ops.push((
740            "_perspt_complete",
741            "Complete a prompt or resource-template argument.",
742            EffectKind::Search,
743            completion_schema(),
744            McpBinding::Complete,
745        ));
746    }
747    ops
748}
749
750async fn call_binding(
751    client: &McpConnection,
752    binding: &McpBinding,
753    arguments: serde_json::Map<String, serde_json::Value>,
754) -> Result<(serde_json::Value, bool)> {
755    let value = match binding {
756        McpBinding::Tool(name) => {
757            let result = client.call_tool(name.clone(), arguments).await?;
758            return Ok((
759                serde_json::to_value(&result)?,
760                result.is_error.unwrap_or(false),
761            ));
762        }
763        McpBinding::ResourcesList => serde_json::to_value(client.list_resources().await?)?,
764        McpBinding::ResourceTemplatesList => {
765            serde_json::to_value(client.list_resource_templates().await?)?
766        }
767        McpBinding::ResourceRead => {
768            let uri = required_string(&arguments, "uri")?;
769            serde_json::to_value(client.read_resource(uri).await?)?
770        }
771        McpBinding::PromptsList => serde_json::to_value(client.list_prompts().await?)?,
772        McpBinding::PromptGet => {
773            let name = required_string(&arguments, "name")?;
774            let prompt_args = prompt_args(&arguments)?;
775            serde_json::to_value(client.get_prompt(name, prompt_args).await?)?
776        }
777        McpBinding::Complete => complete(client, &arguments).await?,
778    };
779    Ok((value, false))
780}
781
782async fn complete(
783    client: &McpConnection,
784    arguments: &serde_json::Map<String, serde_json::Value>,
785) -> Result<serde_json::Value> {
786    let kind = required_string(arguments, "kind")?;
787    let target = required_string(arguments, "reference")?;
788    let reference = match kind.as_str() {
789        "prompt" => rmcp::model::Reference::for_prompt(target),
790        "resource" => rmcp::model::Reference::for_resource(target),
791        _ => anyhow::bail!("MCP completion kind must be prompt or resource"),
792    };
793    let request = rmcp::model::CompleteRequestParams::new(
794        reference,
795        rmcp::model::ArgumentInfo::new(
796            required_string(arguments, "argument")?,
797            required_string(arguments, "value")?,
798        ),
799    );
800    Ok(serde_json::to_value(client.complete(request).await?)?)
801}
802
803fn empty_object_schema() -> serde_json::Value {
804    string_schema(&[])
805}
806
807fn completion_schema() -> serde_json::Value {
808    string_schema(&[
809        ("kind", "Either prompt or resource", true),
810        ("reference", "Prompt name or resource URI template", true),
811        ("argument", "Argument name", true),
812        ("value", "Current partial value", true),
813    ])
814}
815
816fn prompt_get_schema() -> serde_json::Value {
817    serde_json::json!({
818        "type": "object",
819        "properties": {
820            "name": {"type": "string", "description": "Exact prompt name"},
821            "argument_names": {
822                "type": "array",
823                "description": "Optional prompt argument names, paired by index with argument_values",
824                "items": {"type": "string"},
825                "maxItems": 64
826            },
827            "argument_values": {
828                "type": "array",
829                "description": "Optional prompt argument values, paired by index with argument_names",
830                "items": {"type": "string"},
831                "maxItems": 64
832            }
833        },
834        "required": ["name"],
835        "additionalProperties": false
836    })
837}
838
839fn prompt_args(
840    arguments: &serde_json::Map<String, serde_json::Value>,
841) -> Result<Option<rmcp::model::JsonObject>> {
842    let names = string_array(arguments, "argument_names")?;
843    let values = string_array(arguments, "argument_values")?;
844    if names.is_none() && values.is_none() {
845        return Ok(None);
846    }
847    let names = names.context("argument_names is required when argument_values is present")?;
848    let values = values.context("argument_values is required when argument_names is present")?;
849    anyhow::ensure!(
850        names.len() == values.len(),
851        "argument_names and argument_values must have equal lengths"
852    );
853    let mut result = rmcp::model::JsonObject::new();
854    for (name, value) in names.into_iter().zip(values) {
855        anyhow::ensure!(
856            result
857                .insert(name.clone(), serde_json::Value::String(value))
858                .is_none(),
859            "duplicate MCP prompt argument {name:?}"
860        );
861    }
862    Ok(Some(result))
863}
864
865fn string_array(
866    arguments: &serde_json::Map<String, serde_json::Value>,
867    field: &str,
868) -> Result<Option<Vec<String>>> {
869    arguments
870        .get(field)
871        .map(|value| {
872            value
873                .as_array()
874                .with_context(|| format!("{field} must be an array"))?
875                .iter()
876                .map(|item| {
877                    item.as_str()
878                        .map(str::to_owned)
879                        .with_context(|| format!("{field} entries must be strings"))
880                })
881                .collect()
882        })
883        .transpose()
884}
885
886fn string_schema(fields: &[(&str, &str, bool)]) -> serde_json::Value {
887    let properties: serde_json::Map<String, serde_json::Value> = fields
888        .iter()
889        .map(|(name, description, _)| {
890            (
891                (*name).to_string(),
892                serde_json::json!({"type": "string", "description": description}),
893            )
894        })
895        .collect();
896    let required: Vec<&str> = fields
897        .iter()
898        .filter_map(|(name, _, required)| required.then_some(*name))
899        .collect();
900    serde_json::json!({
901        "type": "object",
902        "properties": properties,
903        "required": required,
904        "additionalProperties": false,
905    })
906}
907
908fn required_string(
909    arguments: &serde_json::Map<String, serde_json::Value>,
910    field: &str,
911) -> Result<String> {
912    arguments
913        .get(field)
914        .and_then(serde_json::Value::as_str)
915        .map(str::to_string)
916        .with_context(|| format!("MCP operation requires string argument {field:?}"))
917}
918
919/// Canonical replay key for a recorded external observation (public for
920/// replay assembly and conformance fixtures).
921pub fn replay_key_for(name: &str, arguments: &serde_json::Value) -> Result<String> {
922    replay_key(name, arguments)
923}
924
925fn namespace(server_id: &str, remote_name: &str) -> Result<String> {
926    anyhow::ensure!(
927        !remote_name.is_empty()
928            && remote_name.chars().all(|character| {
929                character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.')
930            }),
931        "MCP tool name {remote_name:?} contains unsupported characters"
932    );
933    Ok(format!("mcp.{server_id}.{remote_name}"))
934}
935
936fn replay_key(name: &str, arguments: &serde_json::Value) -> Result<String> {
937    let canonical = canonical_json(arguments);
938    Ok(format!("{name}:{}", serde_json::to_string(&canonical)?))
939}
940
941fn canonical_json(value: &serde_json::Value) -> serde_json::Value {
942    match value {
943        serde_json::Value::Object(object) => {
944            let sorted: BTreeMap<_, _> = object
945                .iter()
946                .map(|(key, value)| (key.clone(), canonical_json(value)))
947                .collect();
948            serde_json::to_value(sorted).expect("JSON map serialization is infallible")
949        }
950        serde_json::Value::Array(values) => {
951            serde_json::Value::Array(values.iter().map(canonical_json).collect())
952        }
953        scalar => scalar.clone(),
954    }
955}
956
957#[cfg(test)]
958mod tests {
959    use super::*;
960    use perspt_sdk::{ActorId, Capability};
961
962    fn config() -> Config {
963        Config::from_toml_str(
964            r#"
965            [[external_tools]]
966            id = "agent-only"
967            transport = "stdio"
968            command = ["does-not-start-during-construction"]
969
970            [[external_tools]]
971            id = "both"
972            transport = "stdio"
973            command = ["also-lazy"]
974            modes = ["agent", "chat"]
975            "#,
976        )
977        .unwrap()
978    }
979
980    #[test]
981    fn construction_is_lazy() {
982        let capabilities = vec![Capability::new(
983            ActorId::new("chat"),
984            vec![EffectKind::RunShell],
985        )];
986        let chat =
987            ExternalToolRuntime::from_config(&config(), ExternalToolMode::Chat, capabilities)
988                .unwrap();
989        assert_eq!(chat.server_ids(), vec!["both"]);
990        assert_eq!(
991            chat.state("both"),
992            Some(ExternalConnectionState::Configured)
993        );
994    }
995
996    #[test]
997    fn namespace_is_safe() {
998        assert_eq!(namespace("docs", "find").unwrap(), "mcp.docs.find");
999        assert!(namespace("docs", "bad\nname").is_err());
1000    }
1001
1002    #[test]
1003    fn replay_keys_are_canonical() {
1004        let left = serde_json::json!({"a": 1, "b": 2});
1005        let right: serde_json::Value = serde_json::from_str("{\"b\":2,\"a\":1}").unwrap();
1006        assert_eq!(
1007            replay_key("mcp.s.t", &left).unwrap(),
1008            replay_key("mcp.s.t", &right).unwrap()
1009        );
1010    }
1011
1012    #[test]
1013    fn prompt_args_pair() {
1014        let valid = serde_json::json!({
1015            "argument_names": ["topic"],
1016            "argument_values": ["MCP"]
1017        });
1018        let parsed = prompt_args(valid.as_object().unwrap()).unwrap().unwrap();
1019        assert_eq!(parsed["topic"], "MCP");
1020
1021        let invalid = serde_json::json!({
1022            "argument_names": ["topic"],
1023            "argument_values": []
1024        });
1025        assert!(prompt_args(invalid.as_object().unwrap()).is_err());
1026    }
1027}