Skip to main content

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;