agentd/mcp/mod.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2// The MCP client itself lives in the reusable `mcp` crate; re-export it so that
3// `crate::mcp::client::{McpClient, McpError}` resolves here. [`from_spec`] below is
4// the agentd-side integration — config, auth and identity — that belongs in this crate.
5pub use ::mcp::client;
6// Re-export the transport module so the agentd integration (and its tests) can
7// name the `RequestSigner` seam that credential providers plug into.
8pub use ::mcp::http;
9
10/// The **service pace registry** behind a catalog entry's `rate:`: one
11/// process-global map, seeded at the [`from_spec`] chokepoint — the ONLY place
12/// every process (reactor, turn worker, flat subagent) constructs its MCP
13/// clients — so a rated catalog entry paces its consumers wherever the call
14/// actually executes. Buckets are keyed by SERVICE name rather than by server,
15/// so every server referencing one entry shares a single bucket per process.
16pub mod pace {
17 use crate::supervisor::tree::TokenBucket;
18 use std::collections::HashMap;
19 use std::sync::Mutex;
20
21 type Reg = HashMap<String, (String, String)>; // server → (service, rate)
22 static REG: Mutex<Option<Reg>> = Mutex::new(None);
23 static BUCKETS: Mutex<Option<HashMap<String, TokenBucket>>> = Mutex::new(None);
24
25 /// Called from `from_spec` for a spec carrying a catalog rate.
26 pub fn register(server: &str, service: &str, rate: &str) {
27 let mut g = REG.lock().unwrap_or_else(|e| e.into_inner());
28 g.get_or_insert_with(HashMap::new)
29 .insert(server.to_string(), (service.to_string(), rate.to_string()));
30 }
31
32 /// Spend one token toward `server`'s service, if it is rated. `Ok(())`
33 /// when unrated or a token was available; `Err(msg)` (a refusal the
34 /// caller reports as a tool error, never a crash) when the bucket is dry.
35 pub fn take(server: &str) -> Result<(), String> {
36 let (service, rate) = {
37 let g = REG.lock().unwrap_or_else(|e| e.into_inner());
38 match g.as_ref().and_then(|m| m.get(server)) {
39 Some((s, r)) => (s.clone(), r.clone()),
40 None => return Ok(()),
41 }
42 };
43 let (burst, per_s) = crate::supervisor::tree::parse_rate(&rate)
44 .map_err(|e| format!("services.{service}.rate: {e}"))?;
45 let mut g = BUCKETS.lock().unwrap_or_else(|e| e.into_inner());
46 let b = g
47 .get_or_insert_with(HashMap::new)
48 .entry(service.clone())
49 .or_insert_with(|| TokenBucket::new(burst, f64::from(burst) / per_s));
50 if b.try_take() {
51 Ok(())
52 } else {
53 let retry = (per_s / f64::from(burst.max(1))).ceil().max(1.0) as u32;
54 Err(format!(
55 "service '{service}' rate exceeded (services.{service}.rate: {rate} paces this process); retry in ~{retry}s"
56 ))
57 }
58 }
59}
60
61/// Build an MCP client from a declared [`crate::config::McpServerSpec`]: resolve
62/// its secret-free `{{secret:…}}` auth header templates (via [`auth`]) and connect
63/// to the spec's remote `endpoint`, stamping agentd's client identity. The
64/// config/auth-coupled counterpart of the crate's transport-only
65/// [`client::McpClient::connect`]. Call `initialize` on the result before use.
66pub fn from_spec(
67 spec: &crate::config::McpServerSpec,
68 timeout: std::time::Duration,
69) -> Result<client::McpClient, client::McpError> {
70 use client::{McpClient, McpError};
71 if spec.endpoint.trim().is_empty() {
72 return Err(McpError::Transport(format!(
73 "mcp server '{}' has no endpoint",
74 spec.name
75 )));
76 }
77 let headers = auth::resolve_headers(&spec.headers).map_err(McpError::Transport)?;
78 // AAuth: sign requests to this server with the agent identity.
79 // Per-server opt-in — `spec.aauth == Some(false)` opts out; otherwise the
80 // global default is "sign all when an identity is configured". The signing
81 // path is absent entirely without `--features aauth`.
82 #[cfg(feature = "aauth")]
83 let aauth_signer = if spec.aauth == Some(false) {
84 None
85 } else {
86 let s = crate::aauth::signer();
87 // Learn the server's discovery metadata (content-digest requirement)
88 // once at connect (best-effort). Only when we will actually sign it.
89 if s.is_some()
90 && let Some(client) = crate::aauth::installed()
91 {
92 let authority = ::mcp::http::authority_of(&spec.endpoint);
93 client.discover(&authority, &spec.endpoint);
94 }
95 s
96 };
97 #[cfg(not(feature = "aauth"))]
98 let aauth_signer: Option<std::sync::Arc<dyn ::mcp::http::RequestSigner>> = None;
99
100 // Credential precedence: the unified `auth:` block wins (static / oauth2
101 // device-login / client-credentials), then the narrower `oauth:`
102 // client-credentials shortcut, then per-server AAuth signing. An endpoint
103 // presents exactly one mechanism — they are never combined. The `auth:` and
104 // `oauth:` paths are absent without `--features oauth`.
105 #[cfg(feature = "oauth")]
106 let signer: Option<std::sync::Arc<dyn ::mcp::http::RequestSigner>> = if let Some(a) = &spec.auth
107 {
108 // A server that references a catalog entry caches its credential under
109 // `service:<entry>`, so every consumer of that entry shares one login
110 // instead of each provoking its own. A standalone server keys its cache
111 // per-server as `mcp:<name>`.
112 let target = match &spec.service {
113 Some(svc) => format!("service:{svc}"),
114 None => format!("mcp:{}", spec.name),
115 };
116 crate::auth::device::signer_for(a, &target, timeout).map_err(McpError::Transport)?
117 } else if let Some(o) = &spec.oauth {
118 Some(
119 std::sync::Arc::new(oauth::OAuthBearerSigner::new(o.clone(), timeout))
120 as std::sync::Arc<dyn ::mcp::http::RequestSigner>,
121 )
122 } else {
123 aauth_signer
124 };
125 #[cfg(not(feature = "oauth"))]
126 let signer = aauth_signer;
127 // A rated catalog entry paces its consumers within THIS process; registered
128 // here because every process builds its clients through this one function.
129 if let (Some(service), Some(rate)) = (&spec.service, &spec.rate) {
130 pace::register(&spec.name, service, rate);
131 }
132 let client = McpClient::connect_signed(&spec.name, &spec.endpoint, headers, timeout, signer)?
133 .with_client_info(::mcp::wire::Implementation {
134 name: "agentd".into(),
135 version: crate::VERSION.into(),
136 title: None,
137 });
138 // SPIFFE X.509-SVID mTLS: set the transport client identity from the
139 // SPIRE-written cert + key when the server declares `auth: {kind: spiffe,
140 // svid: x509}`. Needs `--features tls` (mTLS); a JWT-SVID rides the signer seam.
141 #[cfg(feature = "tls")]
142 let client = match spiffe_x509_identity(spec)? {
143 Some(id) => client.with_identity(id),
144 None => client,
145 };
146 Ok(client)
147}
148
149/// Build a mutual-TLS client identity from a `kind: spiffe, svid: x509` auth
150/// block (the SPIRE-written cert + key files). `None` for any other auth.
151#[cfg(feature = "tls")]
152fn spiffe_x509_identity(
153 spec: &crate::config::McpServerSpec,
154) -> Result<Option<crate::net::tls::ClientIdentity>, client::McpError> {
155 use client::McpError;
156 let Some(a) = &spec.auth else {
157 return Ok(None);
158 };
159 if a.kind != "spiffe" || a.svid.as_deref() != Some("x509") {
160 return Ok(None);
161 }
162 let cert_path = a
163 .svid_file
164 .as_deref()
165 .ok_or_else(|| McpError::Transport("spiffe x509: svid_file is required".into()))?;
166 let key_path = a
167 .key_file
168 .as_deref()
169 .ok_or_else(|| McpError::Transport("spiffe x509: key_file is required".into()))?;
170 let cert = std::fs::read(cert_path)
171 .map_err(|e| McpError::Transport(format!("spiffe svid_file: {e}")))?;
172 let key = std::fs::read(key_path)
173 .map_err(|e| McpError::Transport(format!("spiffe key_file: {e}")))?;
174 crate::net::tls::ClientIdentity::from_pem(&cert, &key)
175 .map(Some)
176 .map_err(|e| McpError::Transport(format!("spiffe svid: {e}")))
177}
178
179// Auth material resolution for remote MCP endpoints: materialize secret-free
180// `{{secret:…}}` header templates into wire headers at connect time.
181pub mod auth;
182pub mod elicit;
183// OAuth 2.1 client-credentials (M2M) token source for endpoints sitting behind an
184// OAuth gateway. Feature-gated; dependency-free.
185#[cfg(feature = "oauth")]
186pub mod oauth;
187// Built-in Streamable HTTP mock MCP server (the hidden `--internal-mock-mcp-http`
188// mode) for the test + conformance suites: serves a one-resource reactive MCP over
189// a unix socket, so the harness drives agentd's real HTTP transport end to end.
190// In debug it is always present (so `cargo test` needs no flag); in release it
191// ships only under `internal-mocks`, so the production binary carries no test
192// scaffolding.
193#[cfg(any(feature = "internal-mocks", debug_assertions))]
194pub mod mock_http;
195
196// agentd-as-A2A-client: the remote-A2A-agent delegation backend. Connects to a
197// declared peer over HTTP(S) with the JSON-RPC codec, runs `a2a.SendMessage` and
198// then polls `a2a.GetTask` until the task reaches a terminal state, and returns
199// the distillate. Reuses the wire types from `a2a`; adds no dependencies. The
200// serving side of A2A is `runtime::a2a_server`.
201#[cfg(feature = "a2a")]
202pub mod a2a_client;