aion-integration-acp 0.31.0

The first-party Agent Client Protocol (ACP) adapter for Aion: implements the aion-integrations AgentHarness trait against any ACP agent subprocess over newline-delimited JSON-RPC 2.0 stdio.
Documentation
//! The MCP servers this adapter hands an agent on `session/new` and `session/load`.
//!
//! ACP makes `mcpServers` a REQUIRED member of both requests, and until this module existed the
//! adapter always sent the empty list. That was a stated decision rather than an oversight — a
//! worker document had no way to name a server, so inventing one would have been a guess — but it
//! also meant an agent served through this adapter could reach no tools at all beyond its own.
//!
//! # Why a neutral spec rather than the schema type
//!
//! [`agent_client_protocol_schema::v1::McpServer`] is `#[non_exhaustive]` and carries protocol
//! `_meta`, SSE and (feature-gated) ACP transports this adapter has no way to compose. A composition
//! root — the server's assistant sessions, a worker's harness settings — should state WHAT it wants
//! reachable, not assemble the wire form. So the configuration surface is this small owned enum with
//! exactly the two transports every ACP agent must or may serve, and [`McpServerSpec::to_wire`] is
//! the ONE place the wire form is built.
//!
//! # Names and values
//!
//! Unlike [`aion_integrations::EnvironmentDeclaration`], which is names-only because it declares the
//! AGENT child's environment out of the launching process's, the `env` on a stdio MCP server is a
//! set of pairs: the MCP server is a child of the AGENT, launched by the agent from what this list
//! says, and there is no other channel to it. A caller that must not put a value in a document
//! resolves the value itself and passes the resolved pair here.

use std::path::{Path, PathBuf};

use agent_client_protocol_schema::v1::{
    EnvVariable, HttpHeader, McpServer, McpServerHttp, McpServerStdio,
};
use aion_integrations::HarnessError;

/// One MCP server an agent is told about when a session opens.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum McpServerSpec {
    /// A server the agent launches as a subprocess and speaks to over stdio.
    ///
    /// Every ACP agent MUST support this transport, so it is the one form that needs no capability
    /// check.
    Stdio {
        /// The human-readable name the agent shows for this server's tools.
        name: String,
        /// The server executable. ACP requires an ABSOLUTE path; a relative one is refused at
        /// [`crate::AcpHarness::start`] rather than resolved against an unstated base.
        command: PathBuf,
        /// The executable's arguments, passed verbatim.
        args: Vec<String>,
        /// The environment the AGENT sets when it launches the server. Pairs, not names: this
        /// child belongs to the agent, and there is no other channel to it.
        env: Vec<(String, String)>,
    },
    /// A server reached over HTTP — the Streamable-HTTP MCP transport.
    ///
    /// Only usable against an agent whose `initialize` advertised `mcpCapabilities.http`; see
    /// [`McpServerSpec::requires_http_capability`].
    Http {
        /// The human-readable name the agent shows for this server's tools.
        name: String,
        /// The endpoint URL.
        url: String,
        /// Headers the agent sends on every request to the server — the authorization header for
        /// a server that needs one.
        headers: Vec<(String, String)>,
    },
}

impl McpServerSpec {
    /// The configured name, for diagnostics and refusals.
    #[must_use]
    pub fn name(&self) -> &str {
        match self {
            Self::Stdio { name, .. } | Self::Http { name, .. } => name,
        }
    }

    /// Whether this server can only be served by an agent advertising `mcpCapabilities.http`.
    ///
    /// Stdio is mandatory for every ACP agent, so only the HTTP form asks anything of the
    /// negotiated capabilities.
    #[must_use]
    pub const fn requires_http_capability(&self) -> bool {
        matches!(self, Self::Http { .. })
    }

    /// The absolute path this spec requires, if any — the stdio executable.
    #[must_use]
    pub fn absolute_path(&self) -> Option<&Path> {
        match self {
            Self::Stdio { command, .. } => Some(command.as_path()),
            Self::Http { .. } => None,
        }
    }

    /// Builds the ACP wire form.
    ///
    /// The ONE place the schema types are constructed for this adapter, so a caller cannot compose
    /// a shape the protocol does not accept.
    #[must_use]
    pub fn to_wire(&self) -> McpServer {
        match self {
            Self::Stdio {
                name,
                command,
                args,
                env,
            } => McpServer::Stdio(
                McpServerStdio::new(name.clone(), command.clone())
                    .args(args.clone())
                    .env(
                        env.iter()
                            .map(|(key, value)| EnvVariable::new(key.clone(), value.clone()))
                            .collect(),
                    ),
            ),
            Self::Http { name, url, headers } => McpServer::Http(
                McpServerHttp::new(name.clone(), url.clone()).headers(
                    headers
                        .iter()
                        .map(|(key, value)| HttpHeader::new(key.clone(), value.clone()))
                        .collect(),
                ),
            ),
        }
    }
}

/// Refuses a configured MCP server the negotiated agent cannot serve.
///
/// The refusal is a [`HarnessError::Configuration`] — terminal, never retried — for the reason a
/// relative `cwd` is: the same configuration launches the same agent, which advertises the same
/// capabilities, so a retry re-confirms the wall. Handing the agent an HTTP server it never
/// advertised support for would either be ignored silently (the tools an operator configured are
/// simply absent, with nothing said) or rejected by the agent as an opaque `session/new` failure.
///
/// # Errors
///
/// [`HarnessError::Configuration`] naming the server and the capability, when an HTTP server is
/// configured against an agent that did not advertise `mcpCapabilities.http`.
pub(crate) fn require_transport_support(
    servers: &[McpServerSpec],
    http_supported: bool,
) -> Result<(), HarnessError> {
    if http_supported {
        return Ok(());
    }
    for server in servers {
        if server.requires_http_capability() {
            return Err(HarnessError::configuration(format!(
                "the MCP server `{}` is configured over HTTP, and the configured agent did not \
                 advertise `mcpCapabilities.http` in its initialize response — it cannot reach an \
                 HTTP MCP server, and opening the session without it would silently deprive the \
                 agent of the tools this harness was configured to hand it; serve that server over \
                 stdio, or run this harness against an agent that supports the HTTP transport",
                server.name()
            )));
        }
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn a_stdio_spec_serialises_to_the_wire_form_an_agent_reads() -> Result<(), serde_json::Error> {
        let spec = McpServerSpec::Stdio {
            name: "meridian".to_owned(),
            command: PathBuf::from("/opt/meridian/mcp"),
            args: vec!["--stdio".to_owned()],
            env: vec![("MERIDIAN_HOME".to_owned(), "/var/meridian".to_owned())],
        };
        let wire = serde_json::to_value(spec.to_wire())?;
        assert_eq!(wire["name"], serde_json::json!("meridian"));
        assert_eq!(wire["command"], serde_json::json!("/opt/meridian/mcp"));
        assert_eq!(wire["args"], serde_json::json!(["--stdio"]));
        assert_eq!(
            wire["env"],
            serde_json::json!([{ "name": "MERIDIAN_HOME", "value": "/var/meridian" }])
        );
        // The stdio arm is `#[serde(untagged)]` in the schema: it carries no `type`
        // discriminator, which is exactly how an agent recognises it.
        assert_eq!(wire.get("type"), None);
        Ok(())
    }

    #[test]
    fn an_http_spec_carries_its_type_url_and_headers() -> Result<(), serde_json::Error> {
        let spec = McpServerSpec::Http {
            name: "aion".to_owned(),
            url: "http://127.0.0.1:8080/mcp".to_owned(),
            headers: vec![("authorization".to_owned(), "Bearer t".to_owned())],
        };
        let wire = serde_json::to_value(spec.to_wire())?;
        assert_eq!(wire["type"], serde_json::json!("http"));
        assert_eq!(wire["name"], serde_json::json!("aion"));
        assert_eq!(wire["url"], serde_json::json!("http://127.0.0.1:8080/mcp"));
        assert_eq!(
            wire["headers"],
            serde_json::json!([{ "name": "authorization", "value": "Bearer t" }])
        );
        Ok(())
    }

    #[test]
    fn an_http_server_is_refused_against_an_agent_that_did_not_advertise_http() -> Result<(), String>
    {
        let servers = vec![McpServerSpec::Http {
            name: "aion".to_owned(),
            url: "http://127.0.0.1:8080/mcp".to_owned(),
            headers: Vec::new(),
        }];
        match require_transport_support(&servers, false) {
            Ok(()) => Err("an unadvertised HTTP transport must be refused".to_owned()),
            Err(error) => {
                assert!(matches!(error, HarnessError::Configuration { .. }));
                assert!(
                    error.to_string().contains("aion"),
                    "the refusal must name the server: {error}"
                );
                Ok(())
            }
        }
    }

    #[test]
    fn a_stdio_server_needs_no_capability_because_every_agent_must_serve_it() {
        let servers = vec![McpServerSpec::Stdio {
            name: "meridian".to_owned(),
            command: PathBuf::from("/opt/meridian/mcp"),
            args: Vec::new(),
            env: Vec::new(),
        }];
        assert!(require_transport_support(&servers, false).is_ok());
    }
}