agentd/mcp/mod.rs
1// SPDX-License-Identifier: Apache-2.0
2// The MCP client now lives in the reusable `mcp` crate (`mcp::client`); re-export
3// so `crate::mcp::client::{McpClient, McpError}` keeps resolving. `from_spec`
4// below is the agentd integration (config + auth + identity) that stays here.
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 (RFC 0031) plug into.
8pub use ::mcp::http;
9
10/// Build an MCP client from a declared [`crate::config::McpServerSpec`]: resolve
11/// its secret-free `{{secret:…}}` auth header templates (via [`auth`]) and connect
12/// to the spec's remote `endpoint`, stamping agentd's client identity. The
13/// config/auth-coupled counterpart of the crate's transport-only
14/// [`client::McpClient::connect`]. Call `initialize` on the result before use.
15pub fn from_spec(
16 spec: &crate::config::McpServerSpec,
17 timeout: std::time::Duration,
18) -> Result<client::McpClient, client::McpError> {
19 use client::{McpClient, McpError};
20 if spec.endpoint.trim().is_empty() {
21 return Err(McpError::Transport(format!(
22 "mcp server '{}' has no endpoint",
23 spec.name
24 )));
25 }
26 let headers = auth::resolve_headers(&spec.headers).map_err(McpError::Transport)?;
27 // AAuth (RFC 0023): sign requests to this server with the agent identity.
28 // Per-server opt-in — `spec.aauth == Some(false)` opts out; otherwise the
29 // global default is "sign all when an identity is configured". The signing
30 // path is absent entirely without `--features aauth`.
31 #[cfg(feature = "aauth")]
32 let aauth_signer = if spec.aauth == Some(false) {
33 None
34 } else {
35 let s = crate::aauth::signer();
36 // Learn the server's discovery metadata (content-digest requirement)
37 // once at connect (best-effort). Only when we will actually sign it.
38 if s.is_some()
39 && let Some(client) = crate::aauth::installed()
40 {
41 let authority = ::mcp::http::authority_of(&spec.endpoint);
42 client.discover(&authority, &spec.endpoint);
43 }
44 s
45 };
46 #[cfg(not(feature = "aauth"))]
47 let aauth_signer: Option<std::sync::Arc<dyn ::mcp::http::RequestSigner>> = None;
48
49 // Credential precedence (RFC 0031 §5): the unified `auth:` block wins (static
50 // / oauth2 device-login / client-credentials), then the legacy `oauth:`
51 // client-credentials shortcut, then per-server AAuth signing. An endpoint uses
52 // one mechanism. The `auth:`/`oauth:` paths are absent without `--features oauth`.
53 #[cfg(feature = "oauth")]
54 let signer: Option<std::sync::Arc<dyn ::mcp::http::RequestSigner>> = if let Some(a) = &spec.auth
55 {
56 crate::auth::device::signer_for(a, &format!("mcp:{}", spec.name), timeout)
57 .map_err(McpError::Transport)?
58 } else if let Some(o) = &spec.oauth {
59 Some(
60 std::sync::Arc::new(oauth::OAuthBearerSigner::new(o.clone(), timeout))
61 as std::sync::Arc<dyn ::mcp::http::RequestSigner>,
62 )
63 } else {
64 aauth_signer
65 };
66 #[cfg(not(feature = "oauth"))]
67 let signer = aauth_signer;
68 let client = McpClient::connect_signed(&spec.name, &spec.endpoint, headers, timeout, signer)?
69 .with_client_info(::mcp::wire::Implementation {
70 name: "agentd".into(),
71 version: crate::VERSION.into(),
72 title: None,
73 });
74 // SPIFFE X.509-SVID mTLS (RFC 0031 §9): set the transport client identity from
75 // the SPIRE-written cert + key when the server declares `auth: {kind: spiffe,
76 // svid: x509}`. Needs `--features tls` (mTLS); a JWT-SVID rides the signer seam.
77 #[cfg(feature = "tls")]
78 let client = match spiffe_x509_identity(spec)? {
79 Some(id) => client.with_identity(id),
80 None => client,
81 };
82 Ok(client)
83}
84
85/// Build a mutual-TLS client identity from a `kind: spiffe, svid: x509` auth
86/// block (the SPIRE-written cert + key files). `None` for any other auth.
87#[cfg(feature = "tls")]
88fn spiffe_x509_identity(
89 spec: &crate::config::McpServerSpec,
90) -> Result<Option<crate::net::tls::ClientIdentity>, client::McpError> {
91 use client::McpError;
92 let Some(a) = &spec.auth else {
93 return Ok(None);
94 };
95 if a.kind != "spiffe" || a.svid.as_deref() != Some("x509") {
96 return Ok(None);
97 }
98 let cert_path = a
99 .svid_file
100 .as_deref()
101 .ok_or_else(|| McpError::Transport("spiffe x509: svid_file is required".into()))?;
102 let key_path = a
103 .key_file
104 .as_deref()
105 .ok_or_else(|| McpError::Transport("spiffe x509: key_file is required".into()))?;
106 let cert = std::fs::read(cert_path)
107 .map_err(|e| McpError::Transport(format!("spiffe svid_file: {e}")))?;
108 let key = std::fs::read(key_path)
109 .map_err(|e| McpError::Transport(format!("spiffe key_file: {e}")))?;
110 crate::net::tls::ClientIdentity::from_pem(&cert, &key)
111 .map(Some)
112 .map_err(|e| McpError::Transport(format!("spiffe svid: {e}")))
113}
114
115// The Streamable HTTP client transport (RFC 0004) now lives in the reusable `mcp`
116// crate as `mcp::http`; `client` uses it directly (`::mcp::http`).
117// Auth material resolution for remote MCP endpoints (RFC 0012 §3.7): materialize
118// secret-free `{{secret:…}}` header templates into wire headers at connect time.
119pub mod auth;
120pub mod elicit;
121// OAuth 2.1 client-credentials (M2M) token source for endpoints behind an OAuth
122// gateway (RFC 0006 §auth). Feature-gated; dependency-free.
123#[cfg(feature = "oauth")]
124pub mod oauth;
125// Built-in Streamable HTTP mock MCP server (the hidden `--internal-mock-mcp-http`
126// mode, v2.0.0) for the test + conformance suites: serves a one-resource reactive
127// MCP over a unix socket, so the harness drives agentd's HTTP transport end to end.
128// In debug it's always present (so `cargo test` works with no flag); in release it
129// ships only under `internal-mocks`, so the production binary carries no test
130// scaffolding.
131#[cfg(any(feature = "internal-mocks", debug_assertions))]
132pub mod mock_http;
133
134// A2A client-side wire helpers (`TaskState` + request/response shaping) shared
135// with `a2a_client`. The v1 self-MCP server + v1 A2A server surfaces were removed
136// with the mode cut-over; the v2 A2A server is `runtime::a2a_server`.
137#[cfg(feature = "a2a")]
138// agentd-as-A2A-client: the remote-A2A-agent delegation backend (RFC 0020 §3).
139// Connects to a declared peer over HTTP(S) + the RFC 0004
140// JSON-RPC codec, runs `a2a.SendMessage` then polls `a2a.GetTask` to a terminal
141// state, and returns the distillate. Reuses the wire types from `a2a`; no deps.
142#[cfg(feature = "a2a")]
143pub mod a2a_client;