1#[cfg(feature = "harness")]
34use std::collections::HashMap;
35use std::fmt;
36use std::future::Future;
37use std::process::Command as StdCommand;
38use std::sync::Arc;
39use std::time::{Duration, SystemTime};
40
41use rmcp::model::{CallToolRequestParams, ContentBlock, ProtocolVersion};
42use rmcp::service::{ClientCacheConfig, ClientInitializeError, RoleClient, RunningService};
43use rmcp::transport::child_process::TokioChildProcess;
44use rmcp::transport::streamable_http_client::StreamableHttpClientWorker;
45use rmcp::{ClientLifecycleMode, ClientServiceExt};
46use serde::{Deserialize, Serialize};
47
48#[cfg(feature = "harness")]
49use crate::effect::{DisplayFormat, DisplayOutput};
50use crate::effect::{EffectKind, EffectRequest, RiskLevel};
51#[cfg(feature = "harness")]
52use crate::harness::{
53 ClassifiedEffect, EffectExecutor, ExecutionError, ExecutionPolicy, NetworkPolicy,
54 PolicyDecision, PolicyEngine, RawEffectOutput, SandboxPolicy,
55};
56#[cfg(feature = "harness")]
57use crate::run::RunContext;
58use crate::run::RunMetadata;
59use crate::tool::{
60 SideEffectLevel, Tool, ToolContext, ToolError, ToolNamespace, ToolOutput, ToolPolicy,
61 ToolResult, ToolSchema, ToolSource, ToolTrustLevel,
62};
63
64#[derive(Debug)]
66enum ConnSpec {
67 Args { program: String, args: Vec<String> },
70 Command(StdCommand),
74 Consumed,
76 Url(String),
78}
79
80const DEFAULT_CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
83const DEFAULT_LIST_TIMEOUT: Duration = Duration::from_secs(10);
85const DEFAULT_CALL_TIMEOUT: Duration = Duration::from_secs(60);
87
88#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
93pub struct McpServerId(String);
94
95impl McpServerId {
96 pub fn new(id: impl Into<String>) -> Self {
98 Self(id.into())
99 }
100
101 pub fn as_str(&self) -> &str {
103 &self.0
104 }
105}
106
107impl fmt::Display for McpServerId {
108 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
109 f.write_str(&self.0)
110 }
111}
112
113impl From<String> for McpServerId {
114 fn from(value: String) -> Self {
115 Self::new(value)
116 }
117}
118
119impl From<&str> for McpServerId {
120 fn from(value: &str) -> Self {
121 Self::new(value)
122 }
123}
124
125#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
127pub struct McpToolId {
128 pub server: McpServerId,
130 pub raw_name: String,
132}
133
134impl McpToolId {
135 pub fn new(server: impl Into<McpServerId>, raw_name: impl Into<String>) -> Self {
137 Self {
138 server: server.into(),
139 raw_name: raw_name.into(),
140 }
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
146pub struct McpCacheHint {
147 pub ttl_ms: Option<u64>,
150}
151
152#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
154pub struct McpToolDescriptor {
155 pub id: McpToolId,
157 pub display_name: String,
159 pub description: String,
161 pub input_schema: serde_json::Value,
163 pub output_schema: Option<serde_json::Value>,
165 pub annotations: serde_json::Value,
168 pub schema_digest: String,
170 pub cache: Option<McpCacheHint>,
172 pub metadata: RunMetadata,
174}
175
176impl McpToolDescriptor {
177 pub fn source(&self, trust: ToolTrustLevel) -> ToolSource {
180 ToolSource::new(
181 ToolNamespace::mcp_server(self.id.server.as_str()),
182 self.id.raw_name.clone(),
183 self.display_name.clone(),
184 )
185 .with_trust(trust)
186 .with_metadata(self.metadata.clone())
187 }
188}
189
190#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
192pub struct McpToolCatalog {
193 pub server: McpServerId,
195 pub tools: Vec<McpToolDescriptor>,
197 pub fetched_at: SystemTime,
199 pub expires_at: Option<SystemTime>,
201 pub metadata: RunMetadata,
203}
204
205#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
207#[non_exhaustive]
208pub enum McpToolMode {
209 DirectOutput,
211 GovernedEffect,
213}
214
215pub struct McpClient {
245 server_name: String,
246 spec: ConnSpec,
247 prefix: bool,
248 running: Option<Arc<RunningService<RoleClient, ()>>>,
249 connect_timeout: Duration,
252 list_timeout: Duration,
254 call_timeout: Duration,
256}
257
258impl McpClient {
259 pub fn from_command(
280 server_name: impl Into<String>,
281 program: impl Into<String>,
282 args: impl IntoIterator<Item = impl Into<String>>,
283 ) -> Self {
284 Self {
285 server_name: server_name.into(),
286 spec: ConnSpec::Args {
287 program: program.into(),
288 args: args.into_iter().map(Into::into).collect(),
289 },
290 prefix: true,
291 running: None,
292 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
293 list_timeout: DEFAULT_LIST_TIMEOUT,
294 call_timeout: DEFAULT_CALL_TIMEOUT,
295 }
296 }
297
298 pub fn from_command_configured(server_name: impl Into<String>, command: StdCommand) -> Self {
309 Self {
310 server_name: server_name.into(),
311 spec: ConnSpec::Command(command),
312 prefix: true,
313 running: None,
314 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
315 list_timeout: DEFAULT_LIST_TIMEOUT,
316 call_timeout: DEFAULT_CALL_TIMEOUT,
317 }
318 }
319
320 pub fn from_url(server_name: impl Into<String>, url: impl Into<String>) -> Self {
334 Self {
335 server_name: server_name.into(),
336 spec: ConnSpec::Url(url.into()),
337 prefix: true,
338 running: None,
339 connect_timeout: DEFAULT_CONNECT_TIMEOUT,
340 list_timeout: DEFAULT_LIST_TIMEOUT,
341 call_timeout: DEFAULT_CALL_TIMEOUT,
342 }
343 }
344
345 pub fn with_name_prefix(&mut self, enabled: bool) -> &mut Self {
358 self.prefix = enabled;
359 self
360 }
361
362 pub fn with_connect_timeout(&mut self, timeout: Duration) -> &mut Self {
367 self.connect_timeout = timeout;
368 self
369 }
370
371 pub fn with_list_timeout(&mut self, timeout: Duration) -> &mut Self {
373 self.list_timeout = timeout;
374 self
375 }
376
377 pub fn with_call_timeout(&mut self, timeout: Duration) -> &mut Self {
382 self.call_timeout = timeout;
383 self
384 }
385
386 pub fn server_name(&self) -> &str {
388 &self.server_name
389 }
390
391 pub async fn connect(&mut self) -> Result<(), McpError> {
405 if self.running.is_some() {
406 return Ok(());
407 }
408 let server_name = self.server_name.clone();
411 let running = match std::mem::replace(&mut self.spec, ConnSpec::Consumed) {
412 ConnSpec::Args { program, args } => {
415 let result = {
416 let mut command = StdCommand::new(program.clone());
417 command.args(args.clone());
418 let transport =
419 match TokioChildProcess::new(tokio::process::Command::from(command)) {
420 Ok(transport) => transport,
421 Err(e) => {
422 self.spec = ConnSpec::Args { program, args };
428 return Err(McpError::Connect {
429 server: server_name.clone(),
430 message: format!("spawn failed: {e}"),
431 });
432 }
433 };
434 serve_with_timeout(
435 &server_name,
436 self.connect_timeout,
437 ().serve_with_lifecycle(transport, lifecycle_mode()),
438 )
439 .await
440 };
441 self.spec = ConnSpec::Args { program, args };
442 result
443 }
444 ConnSpec::Command(command) => {
447 let program = command.get_program().to_string_lossy().to_string();
448 let transport = TokioChildProcess::new(tokio::process::Command::from(command))
449 .map_err(|e| McpError::Connect {
450 server: server_name.clone(),
451 message: format!("spawn {program}: {e}"),
452 })?;
453 serve_with_timeout(
454 &server_name,
455 self.connect_timeout,
456 ().serve_with_lifecycle(transport, lifecycle_mode()),
457 )
458 .await
459 }
460 ConnSpec::Url(url) => {
463 let result = serve_with_timeout(
464 &server_name,
465 self.connect_timeout,
466 ().serve_with_lifecycle(
467 StreamableHttpClientWorker::<reqwest::Client>::new_simple(url.clone()),
468 lifecycle_mode(),
469 ),
470 )
471 .await;
472 self.spec = ConnSpec::Url(url);
473 result
474 }
475 ConnSpec::Consumed => {
476 return Err(McpError::Connect {
477 server: server_name.clone(),
478 message: "stdio command already consumed; create a new McpClient to reconnect"
479 .into(),
480 });
481 }
482 }?;
483 running
488 .peer()
489 .set_response_cache_config(ClientCacheConfig::disabled())
490 .await;
491 self.running = Some(Arc::new(running));
492 Ok(())
493 }
494
495 pub async fn cleanup(&mut self) -> Result<(), McpError> {
504 self.running = None;
505 Ok(())
506 }
507
508 pub async fn tool_catalog(&mut self) -> Result<McpToolCatalog, McpError> {
520 let tools = self.list_tools_raw().await?;
521 Ok(McpToolCatalog {
522 server: McpServerId::new(self.server_name.clone()),
523 tools: tools
524 .into_iter()
525 .map(|tool| descriptor_from_tool(&self.server_name, tool, self.prefix))
526 .collect(),
527 fetched_at: SystemTime::now(),
528 expires_at: None,
529 metadata: RunMetadata::new(),
530 })
531 }
532
533 pub async fn tools(&mut self) -> Result<Vec<McpDirectTool>, McpError> {
558 self.connect().await?;
559 let Some(running) = self.running.clone() else {
560 return Err(McpError::ListTools {
561 server: self.server_name.clone(),
562 message: "no active connection".into(),
563 });
564 };
565 Ok(self
566 .tool_catalog()
567 .await?
568 .tools
569 .into_iter()
570 .map(|descriptor| {
571 McpDirectTool::new(descriptor, self.call_timeout, Arc::clone(&running))
572 })
573 .collect())
574 }
575
576 #[cfg(feature = "harness")]
584 pub async fn effect_tools(&mut self) -> Result<Vec<McpEffectTool>, McpError> {
585 Ok(self
586 .tool_catalog()
587 .await?
588 .tools
589 .into_iter()
590 .map(McpEffectTool::new)
591 .collect())
592 }
593
594 async fn list_tools_raw(&mut self) -> Result<Vec<rmcp::model::Tool>, McpError> {
595 self.connect().await?;
596 let Some(running) = self.running.clone() else {
597 return Err(McpError::ListTools {
598 server: self.server_name.clone(),
599 message: "no active connection".into(),
600 });
601 };
602 let tools = tokio::time::timeout(self.list_timeout, running.peer().list_all_tools())
603 .await
604 .map_err(|_| McpError::ListTools {
605 server: self.server_name.clone(),
606 message: "list tools timed out".into(),
607 })?
608 .map_err(|e| McpError::ListTools {
609 server: self.server_name.clone(),
610 message: e.to_string(),
611 })?;
612 if tools.len() > MAX_MCP_TOOLS {
615 return Err(McpError::ListTools {
616 server: self.server_name.clone(),
617 message: format!(
618 "server exposes too many tools ({} > {MAX_MCP_TOOLS})",
619 tools.len()
620 ),
621 });
622 }
623 Ok(tools)
624 }
625}
626
627impl std::fmt::Debug for McpClient {
628 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
629 f.debug_struct("McpClient")
630 .field("server_name", &self.server_name)
631 .field("spec", &self.spec)
632 .field("prefix", &self.prefix)
633 .field("connected", &self.running.is_some())
634 .finish()
635 }
636}
637
638#[derive(Clone)]
652pub struct McpDirectTool {
653 name: String,
655 server_id: McpServerId,
657 raw_name: String,
659 description: String,
661 parameters: serde_json::Value,
663 schema_digest: String,
665 annotations: serde_json::Value,
667 call_timeout: Duration,
669 peer: Arc<RunningService<RoleClient, ()>>,
671}
672
673impl McpDirectTool {
674 fn new(
676 descriptor: McpToolDescriptor,
677 call_timeout: Duration,
678 peer: Arc<RunningService<RoleClient, ()>>,
679 ) -> Self {
680 Self {
681 name: descriptor.display_name,
682 server_id: descriptor.id.server,
683 raw_name: descriptor.id.raw_name,
684 description: descriptor.description,
685 parameters: descriptor.input_schema,
686 schema_digest: descriptor.schema_digest,
687 annotations: descriptor.annotations,
688 call_timeout,
689 peer,
690 }
691 }
692
693 pub fn source(&self) -> ToolSource {
696 ToolSource::new(
697 ToolNamespace::mcp_server(self.server_id.as_str()),
698 self.raw_name.clone(),
699 self.name.clone(),
700 )
701 .with_trust(ToolTrustLevel::External)
702 }
703}
704
705#[cfg(test)]
708struct MappedTool {
709 name: String,
710 raw_name: String,
711 description: String,
712 parameters: serde_json::Value,
713}
714
715#[cfg(test)]
717fn map_tool(server_name: &str, tool: rmcp::model::Tool, prefix: bool) -> MappedTool {
718 let raw_name = tool.name.to_string();
719 MappedTool {
720 name: tool_display_name(server_name, &raw_name, prefix),
721 raw_name,
722 description: tool.description.unwrap_or_default().to_string(),
723 parameters: serde_json::Value::Object((*tool.input_schema).clone()),
724 }
725}
726
727fn descriptor_from_tool(
728 server_name: &str,
729 tool: rmcp::model::Tool,
730 prefix: bool,
731) -> McpToolDescriptor {
732 let raw_name = tool.name.to_string();
733 let input_schema = serde_json::Value::Object((*tool.input_schema).clone());
734 let output_schema = tool
735 .output_schema
736 .map(|schema| serde_json::Value::Object((*schema).clone()));
737 let annotations = tool
738 .annotations
739 .and_then(|annotations| serde_json::to_value(annotations).ok())
740 .unwrap_or(serde_json::Value::Null);
741 McpToolDescriptor {
742 id: McpToolId::new(server_name, raw_name.clone()),
743 display_name: tool_display_name(server_name, &raw_name, prefix),
744 description: tool.description.unwrap_or_default().to_string(),
745 schema_digest: schema_digest(&input_schema),
746 input_schema,
747 output_schema,
748 annotations,
749 cache: None,
750 metadata: RunMetadata::new(),
751 }
752}
753
754fn schema_digest(schema: &serde_json::Value) -> String {
755 const FNV_OFFSET: u64 = 0xcbf29ce484222325;
756 const FNV_PRIME: u64 = 0x100000001b3;
757
758 let text = serde_json::to_string(schema).unwrap_or_else(|_| schema.to_string());
759 let mut hash = FNV_OFFSET;
760 for byte in text.as_bytes() {
761 hash ^= u64::from(*byte);
762 hash = hash.wrapping_mul(FNV_PRIME);
763 }
764 format!("fnv64:{hash:016x}")
765}
766
767#[cfg(feature = "harness")]
768fn risk_rank(risk: RiskLevel) -> u8 {
769 match risk {
770 RiskLevel::Low => 0,
771 RiskLevel::Medium => 1,
772 RiskLevel::High => 2,
773 RiskLevel::Critical => 3,
774 _ => 3,
775 }
776}
777
778impl std::fmt::Debug for McpDirectTool {
779 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
780 f.debug_struct("McpDirectTool")
781 .field("server_id", &self.server_id)
782 .field("name", &self.name)
783 .field("raw_name", &self.raw_name)
784 .finish_non_exhaustive()
785 }
786}
787
788#[async_trait::async_trait]
789impl Tool for McpDirectTool {
790 fn schema(&self) -> ToolSchema {
791 let mut metadata = RunMetadata::new();
792 metadata.insert(
793 "mcp_server_id".to_string(),
794 serde_json::json!(self.server_id.as_str()),
795 );
796 metadata.insert(
797 "mcp_raw_tool_name".to_string(),
798 serde_json::json!(self.raw_name),
799 );
800 metadata.insert(
801 "mcp_schema_digest".to_string(),
802 serde_json::json!(self.schema_digest),
803 );
804 metadata.insert("mcp_annotations".to_string(), self.annotations.clone());
805 ToolSchema::new(
806 self.name.clone(),
807 self.description.clone(),
808 self.parameters.clone(),
809 )
810 .with_policy(ToolPolicy {
811 side_effects: SideEffectLevel::External,
812 risk: RiskLevel::Medium,
813 timeout: Some(self.call_timeout),
814 ..Default::default()
815 })
816 .with_metadata(metadata)
817 }
818
819 async fn call(
828 &self,
829 arguments: serde_json::Value,
830 _context: ToolContext<'_>,
831 ) -> Result<ToolResult, ToolError> {
832 let Some(args) = arguments.as_object() else {
833 return Err(ToolError::InvalidArguments(
834 "mcp tool arguments must be a JSON object".into(),
835 ));
836 };
837 let params = CallToolRequestParams::new(self.raw_name.clone()).with_arguments(args.clone());
838 let result = tokio::time::timeout(self.call_timeout, self.peer.peer().call_tool(params))
841 .await
842 .map_err(|_| ToolError::Execution("mcp tool call timed out".into()))?
843 .map_err(|e| ToolError::Execution(format!("mcp tool call failed: {e}")))?;
844 let mut text = content_to_text(&result.content);
845 if let Some(structured) = &result.structured_content {
849 let rendered = serde_json::to_string_pretty(structured).map_err(|e| {
850 ToolError::Execution(format!("structured content serialization failed: {e}"))
851 })?;
852 text = format!("{text}\n[structured]\n{rendered}");
853 }
854 if result.is_error.unwrap_or(false) {
855 let message = if text.is_empty() {
856 "mcp tool reported an error".to_string()
857 } else {
858 text
859 };
860 Err(ToolError::Execution(message))
861 } else {
862 Ok(ToolOutput::text(text).into())
863 }
864 }
865}
866
867#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
869pub struct McpCallPayload {
870 pub server_id: McpServerId,
873 pub tool_name: String,
875 pub display_name: String,
877 pub arguments: serde_json::Value,
879 pub schema_digest: Option<String>,
881 pub protocol_version: Option<String>,
883 pub input_responses: Option<serde_json::Value>,
885 pub request_state: Option<String>,
887 pub metadata: RunMetadata,
889}
890
891impl McpCallPayload {
892 pub fn new(
894 id: McpToolId,
895 display_name: impl Into<String>,
896 arguments: serde_json::Value,
897 ) -> Self {
898 Self {
899 server_id: id.server,
900 tool_name: id.raw_name,
901 display_name: display_name.into(),
902 arguments,
903 schema_digest: None,
904 protocol_version: None,
905 input_responses: None,
906 request_state: None,
907 metadata: RunMetadata::new(),
908 }
909 }
910
911 pub fn with_schema_digest(mut self, digest: impl Into<String>) -> Self {
913 self.schema_digest = Some(digest.into());
914 self
915 }
916
917 pub fn with_metadata(mut self, metadata: RunMetadata) -> Self {
919 self.metadata = metadata;
920 self
921 }
922
923 pub fn into_effect(self) -> Result<EffectRequest, McpError> {
930 let description = format!(
931 "call MCP tool {} on server {}",
932 self.tool_name, self.server_id
933 );
934 let metadata = self.metadata.clone();
935 let request = EffectRequest::new(
936 EffectKind::Mcp,
937 description,
938 serde_json::to_value(&self).map_err(|e| McpError::InvalidPayload(e.to_string()))?,
939 )
940 .with_risk(RiskLevel::Medium)
941 .with_metadata(metadata);
942 Ok(request)
943 }
944
945 pub fn from_effect(request: &EffectRequest) -> Result<Self, McpError> {
952 if request.kind != EffectKind::Mcp {
953 return Err(McpError::InvalidPayload(format!(
954 "expected EffectKind::Mcp, got {:?}",
955 request.kind
956 )));
957 }
958 serde_json::from_value(request.payload.clone())
959 .map_err(|e| McpError::InvalidPayload(e.to_string()))
960 }
961}
962
963#[cfg(feature = "harness")]
970#[derive(Debug, Clone)]
971pub struct McpEffectTool {
972 descriptor: McpToolDescriptor,
973 risk: RiskLevel,
974 timeout: Option<Duration>,
975}
976
977#[cfg(feature = "harness")]
978impl McpEffectTool {
979 pub fn new(descriptor: McpToolDescriptor) -> Self {
981 Self {
982 descriptor,
983 risk: RiskLevel::Medium,
984 timeout: None,
985 }
986 }
987
988 pub fn with_risk(mut self, risk: RiskLevel) -> Self {
990 self.risk = risk;
991 self
992 }
993
994 pub fn with_timeout(mut self, timeout: Duration) -> Self {
996 self.timeout = Some(timeout);
997 self
998 }
999
1000 pub fn source(&self) -> ToolSource {
1002 self.descriptor.source(ToolTrustLevel::External)
1003 }
1004}
1005
1006#[cfg(feature = "harness")]
1007#[async_trait::async_trait]
1008impl Tool for McpEffectTool {
1009 fn schema(&self) -> ToolSchema {
1010 let mut metadata = self.descriptor.metadata.clone();
1011 metadata.insert(
1012 "mcp_server_id".to_string(),
1013 serde_json::json!(self.descriptor.id.server.as_str()),
1014 );
1015 metadata.insert(
1016 "mcp_raw_tool_name".to_string(),
1017 serde_json::json!(self.descriptor.id.raw_name),
1018 );
1019 metadata.insert(
1020 "mcp_schema_digest".to_string(),
1021 serde_json::json!(self.descriptor.schema_digest),
1022 );
1023 ToolSchema::new(
1024 self.descriptor.display_name.clone(),
1025 self.descriptor.description.clone(),
1026 self.descriptor.input_schema.clone(),
1027 )
1028 .with_policy(ToolPolicy {
1029 side_effects: SideEffectLevel::External,
1030 risk: self.risk,
1031 timeout: self.timeout,
1032 ..Default::default()
1033 })
1034 .with_metadata(metadata)
1035 }
1036
1037 async fn call(
1038 &self,
1039 arguments: serde_json::Value,
1040 context: ToolContext<'_>,
1041 ) -> Result<ToolResult, ToolError> {
1042 if !arguments.is_object() {
1043 return Err(ToolError::InvalidArguments(
1044 "mcp tool arguments must be a JSON object".into(),
1045 ));
1046 }
1047 let mut metadata = self.descriptor.metadata.clone();
1048 metadata.insert(
1049 "source_tool_call_id".to_string(),
1050 serde_json::json!(context.tool_call_id),
1051 );
1052 metadata.insert(
1053 "source_tool_name".to_string(),
1054 serde_json::json!(context.tool_name),
1055 );
1056 let payload = McpCallPayload::new(
1057 self.descriptor.id.clone(),
1058 self.descriptor.display_name.clone(),
1059 arguments,
1060 )
1061 .with_schema_digest(self.descriptor.schema_digest.clone())
1062 .with_metadata(metadata);
1063 let mut request = payload
1064 .into_effect()
1065 .map_err(|e| ToolError::Execution(e.to_string()))?
1066 .with_source(context.tool_call_id, context.tool_name)
1067 .with_risk(self.risk);
1068 if let Some(timeout) = self.timeout {
1069 request = request.with_timeout(timeout);
1070 }
1071 Ok(ToolResult::Effect(request))
1072 }
1073}
1074
1075#[cfg(feature = "harness")]
1077#[derive(Debug, Clone, PartialEq)]
1078pub struct McpToolCallOutput {
1079 pub content: String,
1081 pub structured_content: Option<serde_json::Value>,
1083 pub is_error: bool,
1085 pub metadata: RunMetadata,
1087}
1088
1089#[cfg(feature = "harness")]
1090impl McpToolCallOutput {
1091 pub fn text(content: impl Into<String>) -> Self {
1093 Self {
1094 content: content.into(),
1095 structured_content: None,
1096 is_error: false,
1097 metadata: RunMetadata::new(),
1098 }
1099 }
1100
1101 pub fn into_error(mut self) -> Self {
1103 self.is_error = true;
1104 self
1105 }
1106}
1107
1108#[cfg(feature = "harness")]
1114#[async_trait::async_trait]
1115pub trait McpClientProvider: Send + Sync {
1116 async fn call_tool(
1118 &self,
1119 payload: &McpCallPayload,
1120 timeout: Duration,
1121 context: &RunContext,
1122 ) -> Result<McpToolCallOutput, McpError>;
1123}
1124
1125#[cfg(feature = "harness")]
1127#[derive(Debug, Clone)]
1128pub struct McpEffectExecutor<C> {
1129 clients: C,
1130}
1131
1132#[cfg(feature = "harness")]
1133impl<C> McpEffectExecutor<C> {
1134 pub fn new(clients: C) -> Self {
1136 Self { clients }
1137 }
1138}
1139
1140#[cfg(feature = "harness")]
1141#[async_trait::async_trait]
1142impl<C> EffectExecutor for McpEffectExecutor<C>
1143where
1144 C: McpClientProvider,
1145{
1146 async fn execute(
1147 &self,
1148 request: &EffectRequest,
1149 policy: &ExecutionPolicy,
1150 context: &RunContext,
1151 ) -> Result<RawEffectOutput, ExecutionError> {
1152 let payload = McpCallPayload::from_effect(request)
1153 .map_err(|e| ExecutionError::Failed(e.to_string()))?;
1154 let timeout = policy.timeout().unwrap_or(DEFAULT_CALL_TIMEOUT);
1155 let output = self
1156 .clients
1157 .call_tool(&payload, timeout, context)
1158 .await
1159 .map_err(|e| ExecutionError::Failed(e.to_string()))?;
1160 let mut text = output.content;
1161 if let Some(structured) = &output.structured_content {
1162 let rendered = serde_json::to_string_pretty(structured)
1163 .map_err(|e| ExecutionError::Failed(e.to_string()))?;
1164 text = format!("{text}\n[structured]\n{rendered}");
1165 }
1166 if output.is_error {
1167 return Err(ExecutionError::Failed(if text.is_empty() {
1168 "mcp tool reported an error".to_string()
1169 } else {
1170 text
1171 }));
1172 }
1173 Ok(RawEffectOutput::text(text)
1174 .with_display(DisplayOutput::new(
1175 DisplayFormat::PlainText,
1176 "MCP tool call completed",
1177 ))
1178 .with_metadata(output.metadata))
1179 }
1180}
1181
1182#[cfg(feature = "harness")]
1184#[derive(Debug, Clone, PartialEq, Eq)]
1185pub struct McpServerPolicy {
1186 pub server_id: McpServerId,
1188 pub trust: ToolTrustLevel,
1190 pub allowed_tools: Option<Vec<String>>,
1193 pub denied_tools: Vec<String>,
1195 pub default_risk: RiskLevel,
1197 pub require_approval: bool,
1199 pub network: NetworkPolicy,
1201 pub sandbox: SandboxPolicy,
1203}
1204
1205#[cfg(feature = "harness")]
1206impl McpServerPolicy {
1207 pub fn requiring_approval(server_id: impl Into<McpServerId>) -> Self {
1209 Self {
1210 server_id: server_id.into(),
1211 trust: ToolTrustLevel::External,
1212 allowed_tools: None,
1213 denied_tools: Vec::new(),
1214 default_risk: RiskLevel::Medium,
1215 require_approval: true,
1216 network: NetworkPolicy::Deny,
1217 sandbox: SandboxPolicy::ReadOnly,
1218 }
1219 }
1220
1221 pub fn deny_all(server_id: impl Into<McpServerId>) -> Self {
1223 Self {
1224 allowed_tools: Some(Vec::new()),
1225 ..Self::requiring_approval(server_id)
1226 }
1227 }
1228}
1229
1230#[cfg(feature = "harness")]
1232#[derive(Debug, Clone)]
1233pub struct McpPermissionBridge {
1234 server_policies: HashMap<McpServerId, McpServerPolicy>,
1235 default_policy: McpServerPolicy,
1236}
1237
1238#[cfg(feature = "harness")]
1239impl McpPermissionBridge {
1240 pub fn new() -> Self {
1242 Self {
1243 server_policies: HashMap::new(),
1244 default_policy: McpServerPolicy::deny_all("__unknown__"),
1245 }
1246 }
1247
1248 pub fn with_server_policy(mut self, policy: McpServerPolicy) -> Self {
1250 self.server_policies
1251 .insert(policy.server_id.clone(), policy);
1252 self
1253 }
1254
1255 fn policy_for(&self, server_id: &McpServerId) -> &McpServerPolicy {
1256 self.server_policies
1257 .get(server_id)
1258 .unwrap_or(&self.default_policy)
1259 }
1260}
1261
1262#[cfg(feature = "harness")]
1263impl Default for McpPermissionBridge {
1264 fn default() -> Self {
1265 Self::new()
1266 }
1267}
1268
1269#[cfg(feature = "harness")]
1270#[async_trait::async_trait]
1271impl PolicyEngine for McpPermissionBridge {
1272 async fn evaluate(
1273 &self,
1274 effect: &ClassifiedEffect,
1275 _context: &RunContext,
1276 ) -> Result<PolicyDecision, crate::harness::HarnessError> {
1277 if effect.request.kind != EffectKind::Mcp {
1278 return Ok(PolicyDecision::Allow);
1279 }
1280 let payload = McpCallPayload::from_effect(&effect.request).map_err(|e| {
1281 crate::harness::HarnessError::Policy(format!("invalid MCP payload: {e}"))
1282 })?;
1283 let policy = self.policy_for(&payload.server_id);
1284 if policy.server_id != payload.server_id {
1285 return Ok(PolicyDecision::Deny {
1286 reason: format!("unknown MCP server: {}", payload.server_id),
1287 });
1288 }
1289 if policy
1290 .denied_tools
1291 .iter()
1292 .any(|tool| tool == &payload.tool_name)
1293 {
1294 return Ok(PolicyDecision::Deny {
1295 reason: format!("MCP tool denied: {}", payload.tool_name),
1296 });
1297 }
1298 if let Some(allowed) = &policy.allowed_tools
1299 && !allowed.iter().any(|tool| tool == &payload.tool_name)
1300 {
1301 return Ok(PolicyDecision::Deny {
1302 reason: format!("MCP tool not allowed: {}", payload.tool_name),
1303 });
1304 }
1305 if policy.require_approval || risk_rank(effect.effective_risk) >= risk_rank(RiskLevel::High)
1306 {
1307 return Ok(PolicyDecision::RequireApproval {
1308 reason: format!(
1309 "MCP call requires approval: {}::{}",
1310 payload.server_id, payload.tool_name
1311 ),
1312 });
1313 }
1314 Ok(PolicyDecision::Allow)
1315 }
1316}
1317
1318#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1325#[non_exhaustive]
1326pub enum McpError {
1327 #[error("mcp connect failed (server {server}): {message}")]
1331 Connect {
1332 server: String,
1334 message: String,
1336 },
1337 #[error("mcp list tools failed (server {server}): {message}")]
1339 ListTools {
1340 server: String,
1342 message: String,
1344 },
1345 #[error("invalid mcp payload: {0}")]
1347 InvalidPayload(String),
1348 #[error("mcp tool call failed (server {server}, tool {tool}): {message}")]
1350 CallTool {
1351 server: String,
1353 tool: String,
1355 message: String,
1357 },
1358}
1359
1360async fn serve_with_timeout(
1364 server_name: &str,
1365 timeout: Duration,
1366 serve: impl Future<Output = Result<RunningService<RoleClient, ()>, ClientInitializeError>>,
1367) -> Result<RunningService<RoleClient, ()>, McpError> {
1368 match tokio::time::timeout(timeout, serve).await {
1369 Ok(result) => result.map_err(|e| McpError::Connect {
1370 server: server_name.to_string(),
1371 message: e.to_string(),
1372 }),
1373 Err(_) => Err(McpError::Connect {
1374 server: server_name.to_string(),
1375 message: "connect timed out".into(),
1376 }),
1377 }
1378}
1379
1380fn lifecycle_mode() -> ClientLifecycleMode {
1383 ClientLifecycleMode::Auto {
1384 preferred_versions: vec![ProtocolVersion::V_2026_07_28],
1385 legacy_version: Some(ProtocolVersion::V_2025_11_25),
1386 }
1387}
1388
1389fn tool_display_name(server_name: &str, raw: &str, prefix: bool) -> String {
1392 if prefix {
1393 format!("{server_name}__{raw}")
1394 } else {
1395 raw.to_string()
1396 }
1397}
1398
1399const MAX_TOOL_RESULT_BYTES: usize = 1024 * 1024;
1404
1405const MAX_MCP_TOOLS: usize = 512;
1409
1410fn content_to_text(blocks: &[ContentBlock]) -> String {
1414 let mut out = String::new();
1415 for (i, block) in blocks.iter().enumerate() {
1416 let part = match block {
1417 ContentBlock::Text(text) => text.text.clone(),
1418 ContentBlock::Image(image) => format!("[image: {}]", image.mime_type),
1419 ContentBlock::Audio(audio) => format!("[audio: {}]", audio.mime_type),
1420 ContentBlock::Resource(_) => "[resource]".into(),
1421 ContentBlock::ResourceLink(_) => "[resource link]".into(),
1422 _ => {
1426 #[cfg(feature = "tracing")]
1427 tracing::warn!("mcp tool result contains an unknown content block");
1428 "[content]".into()
1429 }
1430 };
1431 if out.len() + part.len() + usize::from(i > 0) > MAX_TOOL_RESULT_BYTES {
1432 out.push_str("[truncated: result exceeds size limit]");
1433 return out;
1434 }
1435 if i > 0 {
1436 out.push('\n');
1437 }
1438 out.push_str(&part);
1439 }
1440 out
1441}
1442
1443#[cfg(test)]
1444mod tests {
1445 use super::*;
1446 use crate::tool::{SharedState, ToolContext, ToolRegistry};
1447 use rmcp::model::{
1448 CallToolResponse, CallToolResult, ListToolsResult, PaginatedRequestParams,
1449 ServerCapabilities, ServerInfo, Tool as RmcpTool,
1450 };
1451 use rmcp::service::{RequestContext, RoleServer};
1452 use rmcp::transport::stdio;
1453 use rmcp::{ErrorData, ServerHandler, serve_server};
1454 use serde_json::json;
1455 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1456
1457 async fn call_mcp_tool(
1458 tool: &McpDirectTool,
1459 arguments: serde_json::Value,
1460 ) -> Result<String, ToolError> {
1461 let run = crate::RunContext::new("mcp-tool-test");
1462 let state = SharedState::new();
1463 let result = tool
1464 .call(
1465 arguments,
1466 ToolContext::new(&run, &state, "call-mcp", &tool.schema().name),
1467 )
1468 .await?;
1469 Ok(result.to_string())
1470 }
1471
1472 #[tokio::test]
1477 async fn http_roundtrip_list_and_call_tools() {
1478 use std::time::Duration;
1479 use tokio::io::{AsyncReadExt, AsyncWriteExt};
1480 use tokio::net::TcpListener;
1481
1482 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
1483 let addr = listener.local_addr().unwrap();
1484
1485 let server = tokio::spawn(async move {
1490 loop {
1491 let (mut socket, _) = match listener.accept().await {
1492 Ok(s) => s,
1493 Err(_) => break,
1494 };
1495 tokio::spawn(async move {
1496 let mut buf = [0u8; 64 * 1024];
1497 let mut bytes = Vec::new();
1498 loop {
1499 let n = match socket.read(&mut buf).await {
1500 Ok(0) => break,
1501 Ok(n) => n,
1502 Err(_) => break,
1503 };
1504 bytes.extend_from_slice(&buf[..n]);
1505 let Some(headers_end) = bytes
1506 .windows(4)
1507 .position(|window| window == b"\r\n\r\n")
1508 .map(|index| index + 4)
1509 else {
1510 continue;
1511 };
1512 let request = String::from_utf8_lossy(&bytes).to_string();
1513 let content_length = request
1514 .lines()
1515 .find_map(|line| {
1516 let lower = line.to_ascii_lowercase();
1517 lower
1518 .strip_prefix("content-length:")
1519 .and_then(|value| value.trim().parse::<usize>().ok())
1520 })
1521 .unwrap_or(0);
1522 if bytes.len() < headers_end + content_length {
1523 continue;
1524 }
1525 let body = String::from_utf8_lossy(&bytes[headers_end..]).to_string();
1526 let Ok(value) = serde_json::from_str::<serde_json::Value>(&body) else {
1527 break;
1528 };
1529 let id = value["id"].clone();
1530 let result = match value["method"].as_str() {
1531 Some("server/discover") => json!({
1532 "resultType": "complete",
1533 "supportedVersions": ["2026-07-28"],
1534 "capabilities": { "tools": {} },
1535 "ttlMs": 0,
1536 "cacheScope": "public",
1537 }),
1538 Some("tools/list") => json!({
1539 "tools": [{
1540 "name": "echo",
1541 "description": "echo",
1542 "inputSchema": { "type": "object", "properties": {} },
1543 }],
1544 "nextCursor": null,
1545 "ttlMs": 0,
1546 "cacheScope": "public",
1547 }),
1548 Some("tools/call") => json!({
1549 "resultType": "complete",
1550 "content": [{ "type": "text", "text": "pong" }],
1551 }),
1552 _ => json!({}),
1553 };
1554 let resp_body =
1555 serde_json::json!({ "jsonrpc": "2.0", "id": id, "result": result })
1556 .to_string();
1557 let resp = format!(
1558 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
1559 resp_body.len(),
1560 resp_body
1561 );
1562 if socket.write_all(resp.as_bytes()).await.is_err() {
1563 break;
1564 }
1565 bytes.clear();
1566 }
1567 });
1568 }
1569 });
1570
1571 let mut client = McpClient::from_url("http-server", format!("http://{addr}"));
1572 let tools = tokio::time::timeout(Duration::from_secs(10), client.tools())
1573 .await
1574 .expect("tools() timed out")
1575 .unwrap();
1576 assert_eq!(tools.len(), 1);
1577 assert_eq!(tools[0].schema().name, "http-server__echo");
1578
1579 let text =
1580 tokio::time::timeout(Duration::from_secs(10), call_mcp_tool(&tools[0], json!({})))
1581 .await
1582 .expect("call timed out")
1583 .unwrap();
1584 assert_eq!(text, "pong");
1585 server.abort();
1586 }
1587
1588 fn spawn_http_mock(
1593 respond: impl Fn(&str, &serde_json::Value) -> Option<serde_json::Value> + Send + Sync + 'static,
1594 ) -> (std::net::SocketAddr, tokio::task::JoinHandle<()>) {
1595 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
1596 listener.set_nonblocking(true).unwrap();
1597 let addr = listener.local_addr().unwrap();
1598 let respond = Arc::new(respond);
1599 let server = tokio::spawn(async move {
1600 let listener = tokio::net::TcpListener::from_std(listener).unwrap();
1601 loop {
1602 let Ok((mut socket, _)) = listener.accept().await else {
1603 break;
1604 };
1605 let respond = Arc::clone(&respond);
1606 tokio::spawn(async move {
1607 let mut buf = [0u8; 8192];
1608 let mut bytes = Vec::new();
1609 loop {
1610 let n = match socket.read(&mut buf).await {
1611 Ok(0) | Err(_) => break,
1612 Ok(n) => n,
1613 };
1614 bytes.extend_from_slice(&buf[..n]);
1615 let Some(headers_end) = bytes
1616 .windows(4)
1617 .position(|window| window == b"\r\n\r\n")
1618 .map(|index| index + 4)
1619 else {
1620 continue;
1621 };
1622 let request = String::from_utf8_lossy(&bytes).to_string();
1623 let content_length = request
1624 .lines()
1625 .find_map(|line| {
1626 let lower = line.to_ascii_lowercase();
1627 lower
1628 .strip_prefix("content-length:")
1629 .and_then(|value| value.trim().parse::<usize>().ok())
1630 })
1631 .unwrap_or(0);
1632 if bytes.len() < headers_end + content_length {
1633 continue;
1634 }
1635 let body = String::from_utf8_lossy(&bytes[headers_end..]).to_string();
1636 let request: serde_json::Value = match serde_json::from_str(&body) {
1637 Ok(v) => v,
1638 Err(_) => break,
1639 };
1640 let method = request["method"].as_str().unwrap_or_default();
1641 let params = request
1642 .get("params")
1643 .cloned()
1644 .unwrap_or(serde_json::Value::Null);
1645 let Some(result) = respond(method, ¶ms) else {
1646 tokio::time::sleep(Duration::from_secs(60)).await;
1650 break;
1651 };
1652 let resp_body = serde_json::json!({
1653 "jsonrpc": "2.0",
1654 "id": request["id"].clone(),
1655 "result": result,
1656 })
1657 .to_string();
1658 let resp = format!(
1659 "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
1660 resp_body.len(),
1661 resp_body
1662 );
1663 if socket.write_all(resp.as_bytes()).await.is_err() {
1664 break;
1665 }
1666 bytes.clear();
1667 }
1668 });
1669 }
1670 });
1671 (addr, server)
1672 }
1673
1674 #[tokio::test]
1675 async fn structured_content_is_rendered_not_dropped() {
1676 let (addr, server) = spawn_http_mock(|method, _| match method {
1679 "server/discover" => Some(json!({
1680 "resultType": "complete",
1681 "supportedVersions": ["2026-07-28"],
1682 "capabilities": { "tools": {} },
1683 "ttlMs": 0,
1684 "cacheScope": "public",
1685 })),
1686 "tools/list" => Some(json!({
1687 "tools": [{
1688 "name": "summary",
1689 "description": "structured",
1690 "inputSchema": { "type": "object", "properties": {} },
1691 }],
1692 "nextCursor": null,
1693 "ttlMs": 0,
1694 "cacheScope": "public",
1695 })),
1696 "tools/call" => Some(json!({
1697 "resultType": "complete",
1698 "content": [{ "type": "text", "text": "hello" }],
1699 "structuredContent": { "answer": 42 },
1701 })),
1702 _ => Some(json!({})),
1703 });
1704 let mut client = McpClient::from_url("s", format!("http://{addr}"));
1705 let tools = client.tools().await.unwrap();
1706 let text = call_mcp_tool(&tools[0], json!({})).await.unwrap();
1707 assert!(text.starts_with("hello"));
1708 assert!(text.contains("[structured]"));
1709 assert!(text.contains("\"answer\""));
1710 server.abort();
1711 }
1712
1713 #[tokio::test]
1714 async fn call_tool_hangs_returns_timeout_error() {
1715 let (addr, server) = spawn_http_mock(|method, _| match method {
1718 "server/discover" => Some(json!({
1719 "resultType": "complete",
1720 "supportedVersions": ["2026-07-28"],
1721 "capabilities": { "tools": {} },
1722 "ttlMs": 0,
1723 "cacheScope": "public",
1724 })),
1725 "tools/list" => Some(json!({
1726 "tools": [{
1727 "name": "hang",
1728 "description": "hangs",
1729 "inputSchema": { "type": "object", "properties": {} },
1730 }],
1731 "nextCursor": null,
1732 "ttlMs": 0,
1733 "cacheScope": "public",
1734 })),
1735 "tools/call" => None,
1736 _ => Some(json!({})),
1737 });
1738 let mut client = McpClient::from_url("s", format!("http://{addr}"));
1739 client.with_call_timeout(Duration::from_millis(100));
1740 let tools = client.tools().await.unwrap();
1741 let err = call_mcp_tool(&tools[0], json!({})).await.unwrap_err();
1742 assert!(matches!(&err, ToolError::Execution(msg) if msg == "mcp tool call timed out"));
1743 server.abort();
1744 }
1745
1746 #[derive(Default)]
1749 struct FakeServer;
1750
1751 fn tool_schema(value: serde_json::Value) -> serde_json::Map<String, serde_json::Value> {
1754 value
1755 .as_object()
1756 .expect("tool schema must be an object")
1757 .clone()
1758 }
1759
1760 impl ServerHandler for FakeServer {
1761 fn get_info(&self) -> ServerInfo {
1762 ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
1763 }
1764
1765 async fn list_tools(
1766 &self,
1767 _request: Option<PaginatedRequestParams>,
1768 _context: RequestContext<RoleServer>,
1769 ) -> Result<ListToolsResult, ErrorData> {
1770 Ok(ListToolsResult {
1771 tools: vec![
1772 RmcpTool::new(
1773 "echo",
1774 "returns the text field verbatim",
1775 tool_schema(json!({
1776 "type": "object",
1777 "properties": { "text": { "type": "string" } },
1778 "required": ["text"],
1779 })),
1780 ),
1781 RmcpTool::new(
1782 "fail",
1783 "always fails at tool level",
1784 tool_schema(json!({ "type": "object" })),
1785 ),
1786 ],
1787 ..Default::default()
1788 })
1789 }
1790
1791 async fn call_tool(
1792 &self,
1793 request: CallToolRequestParams,
1794 _context: RequestContext<RoleServer>,
1795 ) -> Result<CallToolResponse, ErrorData> {
1796 match request.name.as_ref() {
1797 "echo" => {
1798 let text = request
1799 .arguments
1800 .as_ref()
1801 .and_then(|args| args.get("text"))
1802 .and_then(|v| v.as_str())
1803 .unwrap_or_default();
1804 Ok(CallToolResult::success(vec![ContentBlock::text(text.to_string())]).into())
1805 }
1806 "fail" => Ok(CallToolResult::error(vec![ContentBlock::text("boom")]).into()),
1807 name => Err(ErrorData::invalid_params(
1808 format!("unknown tool: {name}"),
1809 None,
1810 )),
1811 }
1812 }
1813 }
1814
1815 #[test]
1818 fn display_name_with_prefix_on() {
1819 assert_eq!(tool_display_name("fs", "read_file", true), "fs__read_file");
1820 }
1821
1822 #[test]
1823 fn display_name_with_prefix_off() {
1824 assert_eq!(tool_display_name("fs", "read_file", false), "read_file");
1825 }
1826
1827 #[test]
1828 fn content_to_text_joins_text_blocks() {
1829 let blocks = vec![
1830 ContentBlock::text("first line"),
1831 ContentBlock::text("second line"),
1832 ];
1833 assert_eq!(content_to_text(&blocks), "first line\nsecond line");
1834 }
1835
1836 #[test]
1837 fn content_to_text_placeholders_non_text() {
1838 let blocks = vec![
1839 ContentBlock::text("take a look at this:"),
1840 ContentBlock::image("base64data", "image/png"),
1841 ContentBlock::audio("base64data", "audio/wav"),
1842 ];
1843 assert_eq!(
1844 content_to_text(&blocks),
1845 "take a look at this:\n[image: image/png]\n[audio: audio/wav]"
1846 );
1847 }
1848
1849 #[test]
1850 fn content_to_text_empty() {
1851 assert_eq!(content_to_text(&[]), "");
1852 }
1853
1854 #[test]
1855 fn map_tool_keeps_prefix_and_passthrough() {
1856 let tool = RmcpTool::new(
1857 "echo",
1858 "description",
1859 tool_schema(json!({ "type": "object" })),
1860 );
1861 let mapped = map_tool("fs", tool, true);
1862 assert_eq!(mapped.name, "fs__echo");
1863 assert_eq!(mapped.raw_name, "echo");
1864 assert_eq!(mapped.description, "description");
1865 assert_eq!(mapped.parameters, json!({ "type": "object" }));
1866 }
1867
1868 #[test]
1869 fn descriptor_keeps_server_scoped_identity_and_digest() {
1870 let tool = RmcpTool::new(
1871 "echo",
1872 "description",
1873 tool_schema(json!({ "type": "object" })),
1874 );
1875 let descriptor = descriptor_from_tool("fake", tool, true);
1876
1877 assert_eq!(descriptor.id.server, McpServerId::new("fake"));
1878 assert_eq!(descriptor.id.raw_name, "echo");
1879 assert_eq!(descriptor.display_name, "fake__echo");
1880 assert!(descriptor.schema_digest.starts_with("fnv64:"));
1881 assert_eq!(
1882 descriptor.source(ToolTrustLevel::External).namespace,
1883 ToolNamespace::mcp_server("fake")
1884 );
1885 }
1886
1887 #[cfg(feature = "harness")]
1888 #[tokio::test]
1889 async fn effect_tool_returns_mcp_effect_without_calling_server() {
1890 let descriptor = descriptor_from_tool(
1891 "fake",
1892 RmcpTool::new(
1893 "echo",
1894 "description",
1895 tool_schema(json!({ "type": "object" })),
1896 ),
1897 true,
1898 );
1899 let tool = McpEffectTool::new(descriptor.clone()).with_timeout(Duration::from_secs(5));
1900 let run = crate::RunContext::new("mcp-effect-test");
1901 let state = SharedState::new();
1902 let result = tool
1903 .call(
1904 json!({ "text": "hello" }),
1905 ToolContext::new(&run, &state, "call-1", "fake__echo"),
1906 )
1907 .await
1908 .unwrap();
1909
1910 let ToolResult::Effect(request) = result else {
1911 panic!("expected effect request");
1912 };
1913 assert_eq!(request.kind, EffectKind::Mcp);
1914 assert_eq!(request.source.tool_call_id.as_deref(), Some("call-1"));
1915 assert_eq!(request.timeout, Some(Duration::from_secs(5)));
1916 let payload = McpCallPayload::from_effect(&request).unwrap();
1917 assert_eq!(payload.server_id, descriptor.id.server);
1918 assert_eq!(payload.tool_name, "echo");
1919 assert_eq!(payload.display_name, "fake__echo");
1920 assert_eq!(payload.arguments, json!({ "text": "hello" }));
1921 assert_eq!(
1922 payload.schema_digest.as_deref(),
1923 Some(descriptor.schema_digest.as_str())
1924 );
1925 }
1926
1927 #[cfg(feature = "harness")]
1928 #[derive(Debug)]
1929 struct FakeMcpProvider {
1930 output: McpToolCallOutput,
1931 }
1932
1933 #[cfg(feature = "harness")]
1934 #[async_trait::async_trait]
1935 impl McpClientProvider for FakeMcpProvider {
1936 async fn call_tool(
1937 &self,
1938 _payload: &McpCallPayload,
1939 _timeout: Duration,
1940 _context: &RunContext,
1941 ) -> Result<McpToolCallOutput, McpError> {
1942 Ok(self.output.clone())
1943 }
1944 }
1945
1946 #[cfg(feature = "harness")]
1947 #[tokio::test]
1948 async fn effect_executor_maps_success_and_tool_error() {
1949 let payload = McpCallPayload::new(
1950 McpToolId::new("fake", "echo"),
1951 "fake__echo",
1952 json!({ "text": "hello" }),
1953 );
1954 let request = payload.into_effect().unwrap();
1955 let policy = ExecutionPolicy::new(SandboxPolicy::ReadOnly, NetworkPolicy::Deny)
1956 .with_timeout(Some(Duration::from_secs(1)));
1957 let run = RunContext::new("mcp-executor-test");
1958
1959 let executor = McpEffectExecutor::new(FakeMcpProvider {
1960 output: McpToolCallOutput::text("hello"),
1961 });
1962 let raw = executor.execute(&request, &policy, &run).await.unwrap();
1963 assert_eq!(raw.observation_for_model, "hello");
1964
1965 let executor = McpEffectExecutor::new(FakeMcpProvider {
1966 output: McpToolCallOutput::text("boom").into_error(),
1967 });
1968 let err = executor.execute(&request, &policy, &run).await.unwrap_err();
1969 assert!(matches!(err, ExecutionError::Failed(message) if message == "boom"));
1970 }
1971
1972 #[cfg(feature = "harness")]
1973 #[tokio::test]
1974 async fn permission_bridge_denies_unknown_and_filters_tools() {
1975 let bridge = McpPermissionBridge::new().with_server_policy(McpServerPolicy {
1976 server_id: McpServerId::new("fake"),
1977 trust: ToolTrustLevel::External,
1978 allowed_tools: Some(vec!["echo".to_string()]),
1979 denied_tools: vec!["delete".to_string()],
1980 default_risk: RiskLevel::Medium,
1981 require_approval: false,
1982 network: NetworkPolicy::Deny,
1983 sandbox: SandboxPolicy::ReadOnly,
1984 });
1985 let run = RunContext::new("mcp-policy-test");
1986 let allowed = McpCallPayload::new(McpToolId::new("fake", "echo"), "fake__echo", json!({}))
1987 .into_effect()
1988 .unwrap();
1989 let denied =
1990 McpCallPayload::new(McpToolId::new("fake", "delete"), "fake__delete", json!({}))
1991 .into_effect()
1992 .unwrap();
1993 let unknown = McpCallPayload::new(
1994 McpToolId::new("unknown", "echo"),
1995 "unknown__echo",
1996 json!({}),
1997 )
1998 .into_effect()
1999 .unwrap();
2000
2001 let decision = bridge.evaluate(&classified(allowed), &run).await.unwrap();
2002 assert_eq!(decision, PolicyDecision::Allow);
2003 let decision = bridge.evaluate(&classified(denied), &run).await.unwrap();
2004 assert!(matches!(decision, PolicyDecision::Deny { .. }));
2005 let decision = bridge.evaluate(&classified(unknown), &run).await.unwrap();
2006 assert!(matches!(decision, PolicyDecision::Deny { .. }));
2007 }
2008
2009 #[cfg(feature = "harness")]
2010 fn classified(request: EffectRequest) -> ClassifiedEffect {
2011 ClassifiedEffect {
2012 request,
2013 requested_risk: RiskLevel::Medium,
2014 effective_risk: RiskLevel::Medium,
2015 reasons: Vec::new(),
2016 metadata: RunMetadata::new(),
2017 }
2018 }
2019
2020 #[tokio::test]
2034 #[ignore]
2035 async fn serve_as_fake_server() {
2036 let running = serve_server(FakeServer, stdio()).await.unwrap();
2040 running.waiting().await.unwrap();
2041 std::process::exit(0);
2045 }
2046
2047 fn fake_server_command() -> (String, Vec<String>) {
2053 let exe = std::env::current_exe()
2054 .unwrap()
2055 .to_string_lossy()
2056 .into_owned();
2057 (exe, vec!["--ignored".into(), "--quiet".into()])
2058 }
2059
2060 #[tokio::test]
2061 async fn tools_roundtrip_echo_and_error() {
2062 let (program, args) = fake_server_command();
2063 let mut client = McpClient::from_command("fake", program, args);
2064 let tools = client.tools().await.unwrap();
2065 assert_eq!(tools.len(), 2);
2066 assert_eq!(tools[0].schema().name, "fake__echo");
2067 assert_eq!(tools[1].schema().name, "fake__fail");
2068
2069 let echo = tools
2070 .iter()
2071 .find(|t| t.schema().name == "fake__echo")
2072 .unwrap();
2073 let result = call_mcp_tool(echo, json!({ "text": "hello" }))
2074 .await
2075 .unwrap();
2076 assert_eq!(result, "hello");
2077
2078 let fail = tools
2081 .iter()
2082 .find(|t| t.schema().name == "fake__fail")
2083 .unwrap();
2084 let err = call_mcp_tool(fail, json!({})).await.unwrap_err();
2085 assert!(matches!(&err, ToolError::Execution(msg) if msg == "boom"));
2086
2087 let err = call_mcp_tool(echo, json!([1, 2])).await.unwrap_err();
2089 assert!(matches!(err, ToolError::InvalidArguments(_)));
2090 }
2091
2092 #[tokio::test]
2093 async fn registry_assembly_with_prefix_off() {
2094 let (program, args) = fake_server_command();
2095 let mut client = McpClient::from_command("fake", program, args);
2096 client.with_name_prefix(false);
2097 let mut registry = ToolRegistry::new();
2098 for tool in client.tools().await.unwrap() {
2099 registry.register(tool);
2100 }
2101 assert_eq!(registry.names(), vec!["echo", "fail"]);
2102 let result = registry
2103 .call_named(
2104 "echo",
2105 r#"{"text":"hi"}"#,
2106 &crate::RunContext::new("mcp-registry-test"),
2107 &SharedState::new(),
2108 )
2109 .await
2110 .unwrap();
2111 assert_eq!(result, "hi");
2112 }
2113
2114 #[tokio::test]
2115 async fn cleanup_then_tools_reconnects() {
2116 let (program, args) = fake_server_command();
2117 let mut client = McpClient::from_command("fake", program, args);
2118 assert_eq!(client.tools().await.unwrap().len(), 2);
2119 client.cleanup().await.unwrap();
2120 assert_eq!(client.tools().await.unwrap().len(), 2);
2123 }
2124
2125 #[tokio::test]
2126 async fn connect_is_idempotent() {
2127 let (program, args) = fake_server_command();
2128 let mut client = McpClient::from_command("fake", program, args);
2129 client.connect().await.unwrap();
2130 client.connect().await.unwrap(); assert_eq!(client.tools().await.unwrap().len(), 2);
2132 }
2133
2134 #[tokio::test]
2135 async fn args_spawn_failure_restores_spec_for_retry() {
2136 let mut client = McpClient::from_command(
2141 "ghost",
2142 "molo-no-such-program-xyz",
2143 std::iter::empty::<&str>(),
2144 );
2145 let err = client.connect().await.unwrap_err();
2146 assert!(
2147 matches!(&err, McpError::Connect { message, .. } if message.contains("spawn failed"))
2148 );
2149 let err2 = client.connect().await.unwrap_err();
2150 assert!(
2151 matches!(&err2, McpError::Connect { message, .. } if message.contains("spawn failed")),
2152 "spec must be restored after spawn failure; retry should fail with spawn error, not already consumed, got: {err2}"
2153 );
2154 }
2155
2156 #[tokio::test]
2157 async fn configured_command_is_one_shot_and_guides_rebuild() {
2158 let (program, _) = fake_server_command();
2159 let mut command = std::process::Command::new(program);
2160 command.args(["--ignored", "--quiet"]);
2161 let mut client = McpClient::from_command_configured("fake", command);
2162 assert_eq!(client.tools().await.unwrap().len(), 2);
2163 client.cleanup().await.unwrap();
2164 let err = client.tools().await.unwrap_err();
2167 assert!(matches!(&err, McpError::Connect { message, .. }
2168 if message.contains("create a new McpClient")));
2169 }
2170
2171 #[tokio::test]
2172 async fn from_url_connect_failure_reports_connect_error() {
2173 let mut client = McpClient::from_url("nowhere", "http://127.0.0.1:1/mcp");
2176 let err = client.connect().await.unwrap_err();
2177 assert!(matches!(&err, McpError::Connect { server, .. } if server == "nowhere"));
2178 assert!(
2179 err.to_string()
2180 .starts_with("mcp connect failed (server nowhere)")
2181 );
2182 }
2183}