1#![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#[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
124pub 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 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 pub fn set_client_services(&mut self, services: McpClientServices) {
213 self.client_services = services;
214 }
215
216 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 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 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 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 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 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
919pub 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}