Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .github/workspace-dep-closures.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@
"crates/macro_tower_layers",
"crates/macro_user_id",
"crates/macro_uuid",
"crates/mcp_client",
"crates/mention_utils",
"crates/model",
"crates/model-entity",
Expand Down Expand Up @@ -212,6 +213,7 @@
"crates/macro_tower_layers",
"crates/macro_user_id",
"crates/macro_uuid",
"crates/mcp_client",
"crates/mention_utils",
"crates/model",
"crates/model-entity",
Expand Down
8 changes: 8 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions crates/agent_egress/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,11 @@ macro_user_id = { path = "../macro_user_id" }
# The rows saying which apps an owner connected, and the client that turns one
# into Pipedream's MCP endpoint plus the headers scoping a call to it.
pipedream_mcp = { path = "../pipedream_mcp" }
# Custom MCP server rows (`mcp_servers`) and their stored OAuth credentials.
# Domain stays free of these types; only the custom outbound adapter reads them.
mcp_client = { path = "../mcp_client", default-features = false }
# `TokenResponse::access_token` on rmcp's stored OAuth grant.
oauth2 = { version = "5.0", default-features = false }
rand = { workspace = true }
reqwest = { workspace = true }
rootcause = { workspace = true }
Expand All @@ -50,5 +55,6 @@ workspace-hack = { version = "0.1", path = "../workspace-hack" }

[dev-dependencies]
anyhow = { workspace = true }
rmcp = { workspace = true, features = ["auth"] }
tokio = { workspace = true, features = ["macros", "rt"] }
tower = { workspace = true }
8 changes: 7 additions & 1 deletion crates/agent_egress/src/domain/error.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
//! Failures a proxied call can end in.

use crate::domain::model::{McpServerSlug, RepoSlug};
use crate::domain::model::{CustomMcpId, McpServerSlug, RepoSlug};

/// Everything that can stop a sandbox's request from reaching its upstream.
///
Expand Down Expand Up @@ -41,6 +41,12 @@ pub enum EgressError {
#[error("no connected MCP server named {0}")]
UnknownServer(McpServerSlug),

/// The owner has no custom server under this id. Same caller-facing
/// meaning as [`EgressError::UnknownServer`]: missing, disabled, or no
/// token are one fact.
#[error("no connected MCP server named {0}")]
UnknownCustom(CustomMcpId),

/// We cannot mint a credential for the session's repository: our GitHub
/// App is not installed on it, or the installation belongs to somebody
/// with no connection to the session's owner. Not a refusal of anything
Expand Down
88 changes: 85 additions & 3 deletions crates/agent_egress/src/domain/model.rs
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,84 @@ impl fmt::Display for McpServerSlug {
}
}

/// Stable path id for a custom MCP server: the first 16 lowercase hex chars
/// of SHA-256 over the server URL bytes.
///
/// A hash rather than the URL itself, because the URL is not a path segment
/// and must never appear in the sandbox's server list as a credentialed
/// destination the model can rewrite.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct CustomMcpId(String);

impl CustomMcpId {
/// Derive the id from a stored server URL.
pub fn from_url(url: &str) -> Self {
Self(
Sha256::digest(url.as_bytes())[..8]
.iter()
.map(|byte| format!("{byte:02x}"))
.collect(),
)
}

/// Accept a path segment that is already this id.
///
/// Rejects rather than repairs: uppercase or a different length is not
/// this id, and folding it into one would let a sandbox name one server
/// and reach another.
pub fn parse(segment: &str) -> Option<Self> {
let valid = segment.len() == 16
&& segment
.bytes()
.all(|byte| matches!(byte, b'0'..=b'9' | b'a'..=b'f'));
valid.then(|| Self(segment.to_owned()))
}

/// The 16-character hex id.
pub fn as_str(&self) -> &str {
&self.0
}
}

impl fmt::Display for CustomMcpId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}

/// An MCP server the harness advertised to a sandbox.
///
/// [`Self::name`] is the ACP / server-list name, not a path segment.
/// Resolution uses the slug or [`CustomMcpId`], never this display name.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum AdvertisedMcp {
/// A Pipedream-connected app, advertised under its app slug.
Pipedream(McpServerSlug),
/// An authenticated custom MCP server from `mcp_servers`.
Custom {
/// Path id the proxy resolves.
id: CustomMcpId,
/// ACP / server-list name.
name: String,
},
}

impl AdvertisedMcp {
/// The name a session's server list shows for this server.
pub fn name(&self) -> &str {
match self {
Self::Pipedream(slug) => slug.as_str(),
Self::Custom { name, .. } => name,
}
}
}

impl fmt::Display for AdvertisedMcp {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.name())
}
}

/// Which half of git's smart-HTTP protocol a request is doing.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum GitService {
Expand Down Expand Up @@ -470,22 +548,26 @@ impl EgressTarget {
match self {
Self::McpServer(McpDestination::Macro) => "macro".to_owned(),
Self::McpServer(McpDestination::Connected(slug)) => slug.as_str().to_owned(),
Self::McpServer(McpDestination::Custom(id)) => id.as_str().to_owned(),
Self::GitHubGit { endpoint } => format!("git {}", endpoint.path_and_query()),
}
}
}

/// Which MCP server a request names.
///
/// Macro's own server and the owner's connected apps live on different
/// routes (`/mcp-macro` vs `/mcp/{slug}`), so they can never collide: there
/// is no reserved word to shadow, and no connected app a name could hide.
/// Macro's own server, the owner's Pipedream apps, and authenticated custom
/// MCP servers live on different routes (`/mcp-macro`, `/mcp/{slug}`,
/// `/mcp-custom/{id}`), so they can never collide: there is no reserved
/// word to shadow, and no connected app a name could hide.
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum McpDestination {
/// Macro's own MCP server, available to every session.
Macro,
/// One of the owner's Pipedream-connected apps.
Connected(McpServerSlug),
/// One of the owner's authenticated custom MCP servers.
Custom(CustomMcpId),
}

/// A resolved destination and the credential to reach it with.
Expand Down
30 changes: 30 additions & 0 deletions crates/agent_egress/src/domain/model/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,36 @@ fn names(headers: &HeaderMap) -> Vec<&str> {
headers.keys().map(HeaderName::as_str).collect()
}

/// The custom id is the first 8 bytes of SHA-256(url), lowercase hex. Both
/// ends derive it the same way, so a stored URL and a path segment meet by
/// equality and nothing is left to normalize.
#[test]
fn custom_id_is_the_first_sixteen_hex_chars_of_sha256() {
let id = CustomMcpId::from_url("https://mcp.example.com/mcp");
assert_eq!(id.as_str().len(), 16);
assert_eq!(id.as_str(), &id.as_str().to_ascii_lowercase());
assert!(
id.as_str()
.bytes()
.all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f'))
);
assert_eq!(CustomMcpId::parse(id.as_str()), Some(id.clone()));
assert_eq!(
CustomMcpId::from_url("https://mcp.example.com/mcp"),
CustomMcpId::from_url("https://mcp.example.com/mcp")
);
}

#[test]
fn custom_id_parse_rejects_rather_than_repairs() {
let id = CustomMcpId::from_url("https://mcp.example.com/mcp");
assert_eq!(CustomMcpId::parse(&id.as_str().to_ascii_uppercase()), None);
assert_eq!(CustomMcpId::parse(&id.as_str()[..15]), None);
assert_eq!(CustomMcpId::parse(&format!("{}a", id.as_str())), None);
assert_eq!(CustomMcpId::parse("not-a-hex-id!!!!"), None);
assert_eq!(CustomMcpId::parse(""), None);
}

/// A slug is a Pipedream `app_slug`, taken as-is: repairing input would let a
/// sandbox name one server and reach another, and there is no derivation for
/// the two ends to disagree over.
Expand Down
27 changes: 24 additions & 3 deletions crates/agent_egress/src/inbound/axum_router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ use std::sync::Arc;

use crate::domain::error::EgressError;
use crate::domain::model::{
BoxError, EgressTarget, GitEndpoint, McpDestination, McpServerSlug, ProxyRequest,
BoxError, CustomMcpId, EgressTarget, GitEndpoint, McpDestination, McpServerSlug, ProxyRequest,
ProxyResponse, SessionToken,
};
use crate::domain::service::EgressService;
Expand Down Expand Up @@ -64,6 +64,7 @@ where
Router::new()
.route("/health", get(health))
.route("/mcp/{slug}", any(mcp_handler::<Service>))
.route("/mcp-custom/{id}", any(custom_mcp_handler::<Service>))
.route("/mcp-macro", any(macro_mcp_handler::<Service>))
.route(
"/git/{*path}",
Expand Down Expand Up @@ -97,6 +98,24 @@ where
mcp_proxy(state, McpDestination::Connected(slug), request).await
}

/// A custom MCP server, on its own route rather than under `/mcp/{slug}`:
/// a Pipedream app slug can be `custom-*`, so that prefix is not a
/// reserved word we can steal.
#[tracing::instrument(skip_all, err, fields(%id))]
async fn custom_mcp_handler<Service>(
State(state): State<EgressRouterState<Service>>,
Path(id): Path<String>,
request: Request,
) -> Result<Response, EgressError>
where
Service: EgressService,
{
let id = CustomMcpId::parse(&id)
.ok_or_else(|| EgressError::Unroutable(format!("{id} is not a server name")))?;

mcp_proxy(state, McpDestination::Custom(id), request).await
}

/// Macro's own MCP server, on its own route rather than under `/mcp/{slug}`:
/// with no name shared between the built-in server and the owner's connected
/// apps, no connected app can collide with it.
Expand Down Expand Up @@ -244,7 +263,9 @@ impl IntoResponse for EgressError {
fn into_response(self) -> Response {
let status = match &self {
Self::Unauthenticated(_) | Self::SessionClosed => StatusCode::UNAUTHORIZED,
Self::UnknownServer(_) | Self::Unroutable(_) => StatusCode::NOT_FOUND,
Self::UnknownServer(_) | Self::UnknownCustom(_) | Self::Unroutable(_) => {
StatusCode::NOT_FOUND
}
Self::RepoUnavailable(_) => StatusCode::FORBIDDEN,
Self::MethodNotAllowed(_) => StatusCode::METHOD_NOT_ALLOWED,
// The named upstream is unusable as configured. Someone has to
Expand All @@ -266,7 +287,7 @@ impl IntoResponse for EgressError {
}
Self::SessionClosed => "This session is no longer open.",
Self::Unroutable(_) => "Nothing is served at that path.",
Self::UnknownServer(_) => "No such connected MCP server.",
Self::UnknownServer(_) | Self::UnknownCustom(_) => "No such connected MCP server.",
Self::RepoUnavailable(_) => {
"This session's repository is not reachable with Macro's GitHub App."
}
Expand Down
22 changes: 22 additions & 0 deletions crates/agent_egress/src/inbound/axum_router/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ impl EgressService for SpyService {
Some(EgressError::Upstream(_)) => {
Err(EgressError::Upstream(rootcause::report!("unreachable")))
}
Some(EgressError::UnknownCustom(id)) => Err(EgressError::UnknownCustom(id.clone())),
Some(_) => Err(EgressError::Unauthenticated("refused by the spy")),
}
}
Expand Down Expand Up @@ -128,6 +129,23 @@ async fn routes_a_slug_to_an_mcp_target() {
);
}

#[tokio::test]
async fn routes_a_hex_id_to_a_custom_mcp_target() {
let id = CustomMcpId::from_url("https://mcp.example.com/mcp");
let service = SpyService::accepting();
let response = call(
&service,
get(&format!("/mcp-custom/{id}"), Some("Bearer session")),
)
.await;

assert_eq!(response.status(), StatusCode::ACCEPTED);
assert_eq!(
service.targets(),
[EgressTarget::McpServer(McpDestination::Custom(id))]
);
}

/// git speaks to us through a credential helper, which can only present a
/// token as a Basic password. Both spellings have to reach the same session.
#[tokio::test]
Expand Down Expand Up @@ -199,6 +217,10 @@ async fn maps_each_refusal_to_the_status_that_tells_the_agent_what_to_do() {
StatusCode::UNAUTHORIZED,
),
(EgressError::SessionClosed, StatusCode::UNAUTHORIZED),
(
EgressError::UnknownCustom(CustomMcpId::from_url("https://mcp.example.com/mcp")),
StatusCode::NOT_FOUND,
),
(
EgressError::RepoUnavailable(RepoSlug::parse("other", "repo").expect("slug")),
StatusCode::FORBIDDEN,
Expand Down
Loading
Loading