From 5d3ccfe34717abd8625ba5fe6436d8927262f923 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 12:39:59 +0000 Subject: [PATCH 1/5] feat(mcp_auth_proxy): dual-mount /mcp and advertise path-style metadata The shared gateway forwards /mcp without stripping it. Mount the broker at / and /mcp so /mcp stays the protocol route and /mcp/health is reachable. WWW-Authenticate now points at /mcp/.well-known/oauth-protected-resource, which the gateway prefix actually forwards. Allow gateway hosts on the Streamable HTTP allowed-hosts list. Co-authored-by: Will Hutchinson --- .../mcp_auth_proxy/src/inbound/axum_router.rs | 15 ++- .../src/inbound/axum_router/test.rs | 91 +++++++++++++++++++ .../mcp_auth_proxy/src/inbound/middleware.rs | 7 +- .../src/inbound/middleware/test.rs | 43 +++++++++ services/mcp_service/src/main.rs | 2 + 5 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 services/mcp_auth_proxy/src/inbound/axum_router/test.rs create mode 100644 services/mcp_auth_proxy/src/inbound/middleware/test.rs diff --git a/services/mcp_auth_proxy/src/inbound/axum_router.rs b/services/mcp_auth_proxy/src/inbound/axum_router.rs index 88ddbd1b764..b071d71a9b5 100644 --- a/services/mcp_auth_proxy/src/inbound/axum_router.rs +++ b/services/mcp_auth_proxy/src/inbound/axum_router.rs @@ -1,5 +1,8 @@ //! Axum router for the MCP OAuth broker. +#[cfg(test)] +mod test; + use std::time::Duration; use axum::{ @@ -21,6 +24,10 @@ use crate::domain::{ }, }; +/// Path prefix the shared gateway ALB forwards unmodified. Dual-mounted +/// alongside `/` so the dedicated ALB keeps working during cutover. +const GATEWAY_PATH_PREFIX: &str = "/mcp"; + /// Health check handler for ALB. async fn health() -> &'static str { "ok" @@ -247,7 +254,13 @@ where super::middleware::validate_bearer, )); - oauth_routes.merge(mcp_route).layer(mcp_cors_layer()) + mount_at_root_and_prefix(oauth_routes.merge(mcp_route)).layer(mcp_cors_layer()) +} + +fn mount_at_root_and_prefix(inner: Router) -> Router { + Router::new() + .merge(inner.clone()) + .nest(GATEWAY_PATH_PREFIX, inner) } /// CORS layer for the MCP router. diff --git a/services/mcp_auth_proxy/src/inbound/axum_router/test.rs b/services/mcp_auth_proxy/src/inbound/axum_router/test.rs new file mode 100644 index 00000000000..dcbc400cce4 --- /dev/null +++ b/services/mcp_auth_proxy/src/inbound/axum_router/test.rs @@ -0,0 +1,91 @@ +use super::{health, mount_at_root_and_prefix}; +use axum::{ + Router, + body::Body, + http::{Request, StatusCode}, + routing::get, +}; +use tower::ServiceExt; + +async fn ok() -> &'static str { + "ok" +} + +fn sample_app() -> Router { + mount_at_root_and_prefix( + Router::new() + .route("/health", get(ok)) + .route("/oauth/callback", get(ok)) + .route("/.well-known/oauth-protected-resource/mcp", get(ok)) + .route("/mcp/.well-known/oauth-protected-resource", get(ok)) + .route("/mcp", get(ok)), + ) +} + +async fn get_status(app: Router, path: &str) -> StatusCode { + app.oneshot( + Request::builder() + .uri(path) + .method("GET") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap() + .status() +} + +#[tokio::test] +async fn health_is_reachable_at_root_and_gateway_prefix() { + for path in ["/health", "/mcp/health"] { + let response = mount_at_root_and_prefix(Router::new().route("/health", get(health))) + .oneshot( + Request::builder() + .uri(path) + .method("GET") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::OK, "{path}"); + } +} + +#[tokio::test] +async fn existing_paths_stay_and_are_also_served_under_the_prefix() { + for path in [ + "/health", + "/mcp/health", + "/oauth/callback", + "/mcp/oauth/callback", + "/.well-known/oauth-protected-resource/mcp", + "/mcp/.well-known/oauth-protected-resource", + "/mcp/.well-known/oauth-protected-resource/mcp", + "/mcp", + "/mcp/mcp", + ] { + assert_eq!( + get_status(sample_app(), path).await, + StatusCode::OK, + "{path}" + ); + } +} + +#[tokio::test] +async fn unprefixed_unknown_path_is_not_rewritten_onto_the_prefix() { + let response = mount_at_root_and_prefix(Router::new().route("/health", get(health))) + .oneshot( + Request::builder() + .uri("/missing") + .method("GET") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/services/mcp_auth_proxy/src/inbound/middleware.rs b/services/mcp_auth_proxy/src/inbound/middleware.rs index f006e54fc9b..5b34f5e0b94 100644 --- a/services/mcp_auth_proxy/src/inbound/middleware.rs +++ b/services/mcp_auth_proxy/src/inbound/middleware.rs @@ -1,5 +1,8 @@ //! Bearer token middleware for the protected MCP endpoint. +#[cfg(test)] +mod test; + use axum::{ body::Body, http::{ @@ -14,7 +17,9 @@ use macro_user_id::user_id::MacroUserIdStr; #[derive(Clone)] pub struct JwtAccessToken(pub String); -const RESOURCE_METADATA_PATH: &str = "/.well-known/oauth-protected-resource/mcp"; +// Path-style well-known sits under `/mcp`, so the gateway prefix forwards it. +// The host-root form (`/.well-known/.../mcp`) is not routed on the gateway. +const RESOURCE_METADATA_PATH: &str = "/mcp/.well-known/oauth-protected-resource"; fn absolute_resource_metadata_url(request: &Request) -> String { let scheme = request diff --git a/services/mcp_auth_proxy/src/inbound/middleware/test.rs b/services/mcp_auth_proxy/src/inbound/middleware/test.rs new file mode 100644 index 00000000000..9296bec4cf6 --- /dev/null +++ b/services/mcp_auth_proxy/src/inbound/middleware/test.rs @@ -0,0 +1,43 @@ +use super::absolute_resource_metadata_url; +use axum::{ + body::Body, + http::{Request, header::HOST}, +}; + +fn request_with_host(host: &str, proto: Option<&str>) -> Request { + let mut builder = Request::builder() + .uri("/mcp") + .method("GET") + .header(HOST, host); + if let Some(proto) = proto { + builder = builder.header("x-forwarded-proto", proto); + } + builder.body(Body::empty()).unwrap() +} + +#[test] +fn resource_metadata_uses_path_style_well_known_on_the_gateway_host() { + let request = request_with_host("gateway.macro.com", Some("https")); + assert_eq!( + absolute_resource_metadata_url(&request), + "https://gateway.macro.com/mcp/.well-known/oauth-protected-resource" + ); +} + +#[test] +fn resource_metadata_uses_path_style_well_known_on_the_legacy_host() { + let request = request_with_host("mcp-server.macro.com", Some("https")); + assert_eq!( + absolute_resource_metadata_url(&request), + "https://mcp-server.macro.com/mcp/.well-known/oauth-protected-resource" + ); +} + +#[test] +fn resource_metadata_defaults_to_http_without_forwarded_proto() { + let request = request_with_host("dev-gateway.macro.com", None); + assert_eq!( + absolute_resource_metadata_url(&request), + "http://dev-gateway.macro.com/mcp/.well-known/oauth-protected-resource" + ); +} diff --git a/services/mcp_service/src/main.rs b/services/mcp_service/src/main.rs index 0571f75dcfc..4a2f5729405 100644 --- a/services/mcp_service/src/main.rs +++ b/services/mcp_service/src/main.rs @@ -55,6 +55,8 @@ async fn main() -> anyhow::Result<()> { context.mcp_public_host.clone(), "localhost".into(), "127.0.0.1".into(), + "gateway.macro.com".into(), + "dev-gateway.macro.com".into(), ]); config.stateful_mode = false; config.json_response = true; From 1e14107dddce7f881e9828ce7aa0a7f2eb859a90 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 12:40:01 +0000 Subject: [PATCH 2/5] feat(infra): attach mcp-server to the gateway at priority 130 Register GatewayService.MCP_SERVER and ServiceUrl.MCP_SERVER_URL. Dual-register ECS in the dedicated and gateway target groups. Add the ExactMatch FusionAuth callback https://{dev-}gateway.macro.com/mcp/oauth/callback next to the legacy mcp-server host. Co-authored-by: Will Hutchinson --- .../packages/shared/src/gateway_priorities.ts | 2 + infra/packages/shared/src/service_urls.ts | 3 ++ infra/stacks/fusionauth-instance/index.ts | 3 ++ infra/stacks/mcp-server/mcp-server.ts | 42 +++++++++++++++++++ 4 files changed, 50 insertions(+) diff --git a/infra/packages/shared/src/gateway_priorities.ts b/infra/packages/shared/src/gateway_priorities.ts index b768bbaac7e..112fa8e4102 100644 --- a/infra/packages/shared/src/gateway_priorities.ts +++ b/infra/packages/shared/src/gateway_priorities.ts @@ -15,6 +15,7 @@ export enum GatewayService { CONNECTION_GATEWAY = 'CONNECTION_GATEWAY', AUTHENTICATION_SERVICE = 'AUTHENTICATION_SERVICE', EMAIL_SERVICE = 'EMAIL_SERVICE', + MCP_SERVER = 'MCP_SERVER', } /** @@ -38,6 +39,7 @@ export const GATEWAY_PRIORITIES: GatewayPriorityMap = { [GatewayService.CONNECTION_GATEWAY]: 90, [GatewayService.AUTHENTICATION_SERVICE]: 100, [GatewayService.EMAIL_SERVICE]: 110, + [GatewayService.MCP_SERVER]: 130, [GatewayService.CONVERT_SERVICE]: 3000, }; diff --git a/infra/packages/shared/src/service_urls.ts b/infra/packages/shared/src/service_urls.ts index f7476cc6859..37389d6d923 100644 --- a/infra/packages/shared/src/service_urls.ts +++ b/infra/packages/shared/src/service_urls.ts @@ -15,6 +15,7 @@ export enum ServiceUrl { LEXICAL_SERVICE_URL = 'LEXICAL_SERVICE_URL', UNFURL_SERVICE_URL = 'UNFURL_SERVICE_URL', AGENT_HARNESS_SERVICE_URL = 'AGENT_HARNESS_SERVICE_URL', + MCP_SERVER_URL = 'MCP_SERVER_URL', } /** @@ -47,6 +48,7 @@ const DEV_SERVICE_URLS: ServiceUrlMap = { [ServiceUrl.UNFURL_SERVICE_URL]: 'https://dev-gateway.macro.com/unfurl', [ServiceUrl.AGENT_HARNESS_SERVICE_URL]: 'https://dev-gateway.macro.com/agent-harness', + [ServiceUrl.MCP_SERVER_URL]: 'https://dev-gateway.macro.com/mcp', }; /** @@ -70,6 +72,7 @@ const PROD_SERVICE_URLS: ServiceUrlMap = { [ServiceUrl.UNFURL_SERVICE_URL]: 'https://gateway.macro.com/unfurl', [ServiceUrl.AGENT_HARNESS_SERVICE_URL]: 'https://gateway.macro.com/agent-harness', + [ServiceUrl.MCP_SERVER_URL]: 'https://gateway.macro.com/mcp', }; /** diff --git a/infra/stacks/fusionauth-instance/index.ts b/infra/stacks/fusionauth-instance/index.ts index 7cfe0b9c309..a818494e587 100644 --- a/infra/stacks/fusionauth-instance/index.ts +++ b/infra/stacks/fusionauth-instance/index.ts @@ -303,6 +303,9 @@ const macroApplication = new FusionAuthApplication( ] : []), `https://mcp-server${stack === 'prod' ? '' : `-${stack}`}.macro.com/oauth/callback`, + ...(stack === 'dev' || stack === 'prod' + ? [`${getServiceUrl(ServiceUrl.MCP_SERVER_URL)}/oauth/callback`] + : []), ...(stack === 'local' || stack === 'dev' ? ['http://localhost:8085/*', 'http://localhost:8085/oauth/*'] : []), diff --git a/infra/stacks/mcp-server/mcp-server.ts b/infra/stacks/mcp-server/mcp-server.ts index f4a810247b5..f9f2dfc71de 100644 --- a/infra/stacks/mcp-server/mcp-server.ts +++ b/infra/stacks/mcp-server/mcp-server.ts @@ -8,16 +8,21 @@ import { datadogAgentContainer, fargateLogRouterSidecarContainer, serviceLoadBalancer, + ServiceTargetGroup, } from '../../packages/resources'; import { EcrImage } from '../../packages/service'; import { BASE_DOMAIN, CLOUD_TRAIL_SNS_TOPIC_ARN, DopplerEcsEnvironment, + getGatewayAlb, getKafkaClusterPolicy, + GatewayService, stack, } from '../../packages/shared'; +const gatewayLoadBalancer = getGatewayAlb(); + const BASE_NAME = pulumi.getProject(); const REPO_ROOT = '../../..'; @@ -107,6 +112,22 @@ export class McpServer extends pulumi.ComponentResource { this.serviceAlbSg = sg.serviceAlbSg; this.serviceSg = sg.serviceSg; + const gatewayTargetGroup = new ServiceTargetGroup( + `${stack}-${BASE_NAME}`, + { + tags: this.tags, + listenerArn: gatewayLoadBalancer.httpsListenerArn, + vpcId: vpc.vpcId, + containerPort: serviceContainerPort, + service: GatewayService.MCP_SERVER, + healthCheckPath, + pathPatterns: ['/mcp', '/mcp/*'], + serviceSecurityGroupId: this.serviceSg.id, + albSecurityGroupId: gatewayLoadBalancer.albSecurityGroupId, + }, + { parent: this } + ); + // lb const { targetGroup, lb, listener } = serviceLoadBalancer(this, { serviceName: BASE_NAME, @@ -245,6 +266,23 @@ export class McpServer extends pulumi.ComponentResource { enable: true, rollback: true, }, + // Register tasks in both the legacy ALB's target group and the gateway + // target group while we migrate to the gateway. An explicit + // `loadBalancers` replaces the list awsx derives from + // `portMappings.targetGroup`, so the legacy entry must be listed here + // too. + loadBalancers: [ + { + targetGroupArn: targetGroup.arn, + containerName: 'service', + containerPort: serviceContainerPort, + }, + { + targetGroupArn: gatewayTargetGroup.target_group.arn, + containerName: 'service', + containerPort: serviceContainerPort, + }, + ], taskDefinitionArgs: { taskRole: { roleArn: this.role.arn, @@ -299,6 +337,10 @@ export class McpServer extends pulumi.ComponentResource { }, { parent: this, + // ECS refuses a service whose target group is not yet associated with + // a load balancer; it is the listener rule that creates that + // association + dependsOn: [gatewayTargetGroup.listener_rule], } ); From f273c8c2e629b673da85499ad3bffef9c069f02c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 12:57:53 +0000 Subject: [PATCH 3/5] fix(mcp_auth_proxy): drop overlapping /mcp well-known routes before nest Hand-registered /mcp/.well-known paths collided with nest of the root well-known routes and panicked at router construction. Let the nest create the prefixed copies. Test mcp_router itself so a stub fixture cannot hide the overlap. Co-authored-by: Will Hutchinson --- infra/stacks/mcp-server/mcp-server.ts | 5 - services/mcp_auth_proxy/Cargo.toml | 1 + services/mcp_auth_proxy/src/inbound.rs | 3 + .../mcp_auth_proxy/src/inbound/axum_router.rs | 14 +- .../src/inbound/axum_router/test.rs | 120 +++++++++++++++++- .../mcp_auth_proxy/src/inbound/middleware.rs | 14 +- 6 files changed, 132 insertions(+), 25 deletions(-) diff --git a/infra/stacks/mcp-server/mcp-server.ts b/infra/stacks/mcp-server/mcp-server.ts index f9f2dfc71de..e58437a06fc 100644 --- a/infra/stacks/mcp-server/mcp-server.ts +++ b/infra/stacks/mcp-server/mcp-server.ts @@ -266,11 +266,6 @@ export class McpServer extends pulumi.ComponentResource { enable: true, rollback: true, }, - // Register tasks in both the legacy ALB's target group and the gateway - // target group while we migrate to the gateway. An explicit - // `loadBalancers` replaces the list awsx derives from - // `portMappings.targetGroup`, so the legacy entry must be listed here - // too. loadBalancers: [ { targetGroupArn: targetGroup.arn, diff --git a/services/mcp_auth_proxy/Cargo.toml b/services/mcp_auth_proxy/Cargo.toml index 0a30b408c82..4fa791d2aa3 100644 --- a/services/mcp_auth_proxy/Cargo.toml +++ b/services/mcp_auth_proxy/Cargo.toml @@ -37,4 +37,5 @@ uuid = { workspace = true } workspace-hack = { version = "0.1", path = "../../crates/workspace-hack" } [dev-dependencies] +macro_auth = { path = "../../crates/macro_auth", features = ["testing"] } tokio = { workspace = true } diff --git a/services/mcp_auth_proxy/src/inbound.rs b/services/mcp_auth_proxy/src/inbound.rs index f76c2e5f6ba..e7e9e216911 100644 --- a/services/mcp_auth_proxy/src/inbound.rs +++ b/services/mcp_auth_proxy/src/inbound.rs @@ -1,5 +1,8 @@ //! Inbound adapters for the MCP OAuth broker. +/// Path the shared gateway forwards without stripping. +pub(crate) const GATEWAY_PATH_PREFIX: &str = "/mcp"; + /// Axum router for the MCP OAuth broker. pub mod axum_router; /// Bearer token middleware for the protected MCP endpoint. diff --git a/services/mcp_auth_proxy/src/inbound/axum_router.rs b/services/mcp_auth_proxy/src/inbound/axum_router.rs index b071d71a9b5..4226a22aff0 100644 --- a/services/mcp_auth_proxy/src/inbound/axum_router.rs +++ b/services/mcp_auth_proxy/src/inbound/axum_router.rs @@ -24,10 +24,6 @@ use crate::domain::{ }, }; -/// Path prefix the shared gateway ALB forwards unmodified. Dual-mounted -/// alongside `/` so the dedicated ALB keeps working during cutover. -const GATEWAY_PATH_PREFIX: &str = "/mcp"; - /// Health check handler for ALB. async fn health() -> &'static str { "ok" @@ -224,10 +220,6 @@ where "/.well-known/oauth-protected-resource/mcp", routing::get(protected_resource_metadata), ) - .route( - "/mcp/.well-known/oauth-protected-resource", - routing::get(protected_resource_metadata), - ) .route( "/.well-known/oauth-authorization-server", routing::get(authorization_server_metadata), @@ -236,10 +228,6 @@ where "/.well-known/oauth-authorization-server/mcp", routing::get(authorization_server_metadata), ) - .route( - "/mcp/.well-known/oauth-authorization-server", - routing::get(authorization_server_metadata), - ) .route("/authorize", routing::get(authorize)) .route("/register", routing::post(register)) .route("/oauth/callback", routing::get(oauth_callback)) @@ -260,7 +248,7 @@ where fn mount_at_root_and_prefix(inner: Router) -> Router { Router::new() .merge(inner.clone()) - .nest(GATEWAY_PATH_PREFIX, inner) + .nest(super::GATEWAY_PATH_PREFIX, inner) } /// CORS layer for the MCP router. diff --git a/services/mcp_auth_proxy/src/inbound/axum_router/test.rs b/services/mcp_auth_proxy/src/inbound/axum_router/test.rs index dcbc400cce4..4a9c9d83055 100644 --- a/services/mcp_auth_proxy/src/inbound/axum_router/test.rs +++ b/services/mcp_auth_proxy/src/inbound/axum_router/test.rs @@ -1,10 +1,20 @@ -use super::{health, mount_at_root_and_prefix}; +use std::sync::Arc; + +use super::{health, mcp_router, mount_at_root_and_prefix}; +use crate::domain::{ + models::{ + AccessToken, IssuedAuthorizationCode, PendingAuthorization, RefreshToken, UpstreamTokens, + }, + ports::OAuthProvider, + service::{InflightAuthStore, McpAuthProxyServiceImpl}, +}; use axum::{ Router, body::Body, http::{Request, StatusCode}, routing::get, }; +use macro_auth::middleware::decode_jwt::JwtValidationArgs; use tower::ServiceExt; async fn ok() -> &'static str { @@ -16,8 +26,8 @@ fn sample_app() -> Router { Router::new() .route("/health", get(ok)) .route("/oauth/callback", get(ok)) + .route("/.well-known/oauth-protected-resource", get(ok)) .route("/.well-known/oauth-protected-resource/mcp", get(ok)) - .route("/mcp/.well-known/oauth-protected-resource", get(ok)) .route("/mcp", get(ok)), ) } @@ -35,6 +45,90 @@ async fn get_status(app: Router, path: &str) -> StatusCode { .status() } +#[derive(Clone, Default)] +struct NoopInflightAuth; + +impl InflightAuthStore for NoopInflightAuth { + async fn insert_pending( + &self, + _session_id: &str, + _pending: PendingAuthorization, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn take_pending( + &self, + _session_id: &str, + ) -> anyhow::Result> { + Ok(None) + } + + async fn insert_issued( + &self, + _code: &str, + _issued: IssuedAuthorizationCode, + ) -> anyhow::Result<()> { + Ok(()) + } + + async fn take_issued(&self, _code: &str) -> anyhow::Result> { + Ok(None) + } + + async fn cleanup_expired(&self) -> anyhow::Result<()> { + Ok(()) + } +} + +struct NoopOAuthProvider; + +impl OAuthProvider for NoopOAuthProvider { + fn construct_authorize_url(&self, state: &str) -> anyhow::Result { + Ok(format!( + "https://upstream.example.com/authorize?state={state}" + )) + } + + fn exchange_authorization_code<'a>( + &'a self, + _code: &'a str, + ) -> crate::domain::ports::UpstreamTokensFuture<'a> { + Box::pin(async { + Ok(UpstreamTokens { + access_token: AccessToken::from("access"), + refresh_token: RefreshToken::from("refresh"), + expires_in: 3600, + }) + }) + } + + fn refresh_access_token<'a>( + &'a self, + _refresh_token: &'a RefreshToken, + ) -> crate::domain::ports::UpstreamTokensFuture<'a> { + Box::pin(async { + Ok(UpstreamTokens { + access_token: AccessToken::from("access"), + refresh_token: RefreshToken::from("refresh"), + expires_in: 3600, + }) + }) + } +} + +fn built_router() -> Router { + mcp_router( + McpAuthProxyServiceImpl::new( + "https://mcp.example.com".to_owned(), + Arc::new(NoopInflightAuth), + Arc::new(NoopOAuthProvider), + ), + JwtValidationArgs::new_testing(), + Router::new().route("/", get(ok)), + ) +} + #[tokio::test] async fn health_is_reachable_at_root_and_gateway_prefix() { for path in ["/health", "/mcp/health"] { @@ -60,6 +154,7 @@ async fn existing_paths_stay_and_are_also_served_under_the_prefix() { "/mcp/health", "/oauth/callback", "/mcp/oauth/callback", + "/.well-known/oauth-protected-resource", "/.well-known/oauth-protected-resource/mcp", "/mcp/.well-known/oauth-protected-resource", "/mcp/.well-known/oauth-protected-resource/mcp", @@ -74,6 +169,27 @@ async fn existing_paths_stay_and_are_also_served_under_the_prefix() { } } +#[tokio::test] +async fn mcp_router_builds_without_overlapping_routes() { + let app = built_router(); + for path in [ + "/health", + "/mcp/health", + "/.well-known/oauth-protected-resource", + "/.well-known/oauth-protected-resource/mcp", + "/mcp/.well-known/oauth-protected-resource", + "/mcp/.well-known/oauth-protected-resource/mcp", + "/.well-known/oauth-authorization-server", + "/mcp/.well-known/oauth-authorization-server", + ] { + assert_eq!( + get_status(app.clone(), path).await, + StatusCode::OK, + "{path}" + ); + } +} + #[tokio::test] async fn unprefixed_unknown_path_is_not_rewritten_onto_the_prefix() { let response = mount_at_root_and_prefix(Router::new().route("/health", get(health))) diff --git a/services/mcp_auth_proxy/src/inbound/middleware.rs b/services/mcp_auth_proxy/src/inbound/middleware.rs index 5b34f5e0b94..dcb9bacfc4e 100644 --- a/services/mcp_auth_proxy/src/inbound/middleware.rs +++ b/services/mcp_auth_proxy/src/inbound/middleware.rs @@ -17,9 +17,12 @@ use macro_user_id::user_id::MacroUserIdStr; #[derive(Clone)] pub struct JwtAccessToken(pub String); -// Path-style well-known sits under `/mcp`, so the gateway prefix forwards it. -// The host-root form (`/.well-known/.../mcp`) is not routed on the gateway. -const RESOURCE_METADATA_PATH: &str = "/mcp/.well-known/oauth-protected-resource"; +fn resource_metadata_path() -> String { + format!( + "{}/.well-known/oauth-protected-resource", + super::GATEWAY_PATH_PREFIX + ) +} fn absolute_resource_metadata_url(request: &Request) -> String { let scheme = request @@ -39,16 +42,17 @@ fn absolute_resource_metadata_url(request: &Request) -> String { }) .unwrap_or("localhost"); + let metadata_path = resource_metadata_path(); let mut uri = Uri::builder() .scheme(scheme) .authority(authority) - .path_and_query(RESOURCE_METADATA_PATH) + .path_and_query(metadata_path.as_str()) .build() .expect("valid resource metadata uri") .to_string(); if !uri.starts_with("http://") && !uri.starts_with("https://") { - uri = format!("{scheme}://{authority}{RESOURCE_METADATA_PATH}"); + uri = format!("{scheme}://{authority}{metadata_path}"); } uri From d74cece938d53fd414933aa3d57d906c60540d89 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 13:16:23 +0000 Subject: [PATCH 4/5] fix(mcp_auth_proxy): advertise required resource in protected-resource metadata MCP TypeScript SDK clients discard PRM without a resource URL and fall back to root AS discovery, which the gateway /mcp rule cannot route. Co-authored-by: Will Hutchinson --- services/mcp_auth_proxy/src/domain/service.rs | 15 +++++++++ .../mcp_auth_proxy/src/domain/service/test.rs | 32 +++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/services/mcp_auth_proxy/src/domain/service.rs b/services/mcp_auth_proxy/src/domain/service.rs index c85ccbae0ee..b0d60d0a716 100644 --- a/services/mcp_auth_proxy/src/domain/service.rs +++ b/services/mcp_auth_proxy/src/domain/service.rs @@ -81,6 +81,20 @@ pub trait InflightAuthStore: Send + Sync { fn cleanup_expired(&self) -> impl Future> + Send; } +/// Streamable HTTP resource URL advertised in protected-resource metadata. +/// +/// MCP clients require a `resource` field. When `MCP_PUBLIC_URL` is an origin +/// (the current Doppler value), append `/mcp`. When it already ends in `/mcp` +/// (a later gateway cutover), do not append again. +fn mcp_resource_url(public_url: &str) -> String { + let base = public_url.trim_end_matches('/'); + if base.ends_with("/mcp") { + base.to_owned() + } else { + format!("{base}/mcp") + } +} + /// Domain service backing the MCP OAuth broker. pub struct McpAuthProxyServiceImpl { inflight_auth: Arc, @@ -205,6 +219,7 @@ where tracing::debug!("oauth-protected-resource metadata requested"); let base = &self.public_url; serde_json::json!({ + "resource": mcp_resource_url(base), "authorization_server": base, "authorization_servers": [base], }) diff --git a/services/mcp_auth_proxy/src/domain/service/test.rs b/services/mcp_auth_proxy/src/domain/service/test.rs index 0c16c7a73c4..901c73e9845 100644 --- a/services/mcp_auth_proxy/src/domain/service/test.rs +++ b/services/mcp_auth_proxy/src/domain/service/test.rs @@ -114,6 +114,38 @@ fn service(store: FakeInflightAuth) -> McpAuthProxyServiceImpl ) } +#[test] +fn protected_resource_metadata_includes_required_resource_url() { + let json = service(FakeInflightAuth::default()).protected_resource_metadata(); + assert_eq!( + json, + serde_json::json!({ + "resource": "https://mcp.example.com/mcp", + "authorization_server": "https://mcp.example.com", + "authorization_servers": ["https://mcp.example.com"], + }) + ); +} + +#[test] +fn protected_resource_metadata_does_not_double_the_mcp_path() { + let service = McpAuthProxyServiceImpl::new( + "https://gateway.macro.com/mcp".to_owned(), + Arc::new(FakeInflightAuth::default()), + Arc::new(FakeOAuthProvider { + expires_in: UPSTREAM_EXPIRES_IN, + }), + ); + assert_eq!( + service.protected_resource_metadata(), + serde_json::json!({ + "resource": "https://gateway.macro.com/mcp", + "authorization_server": "https://gateway.macro.com/mcp", + "authorization_servers": ["https://gateway.macro.com/mcp"], + }) + ); +} + fn issued_code(access_token_expires_at: Option) -> IssuedAuthorizationCode { IssuedAuthorizationCode { access_token: AccessToken::from("upstream-access"), From 21cc222567e9823b39fd95dca84145295cacc7a3 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 8 Sep 2026 13:36:11 +0000 Subject: [PATCH 5/5] fix(infra): wait for dedicated ALB listener before ECS service ECS rejects a service whose target group is not yet associated with a load balancer. Depend on the legacy HTTPS listener as well as the gateway listener rule. Co-authored-by: Will Hutchinson --- infra/stacks/mcp-server/mcp-server.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infra/stacks/mcp-server/mcp-server.ts b/infra/stacks/mcp-server/mcp-server.ts index e58437a06fc..d555d514628 100644 --- a/infra/stacks/mcp-server/mcp-server.ts +++ b/infra/stacks/mcp-server/mcp-server.ts @@ -333,9 +333,9 @@ export class McpServer extends pulumi.ComponentResource { { parent: this, // ECS refuses a service whose target group is not yet associated with - // a load balancer; it is the listener rule that creates that - // association - dependsOn: [gatewayTargetGroup.listener_rule], + // a load balancer. The dedicated HTTPS listener and the gateway + // listener rule each create that association for their target group. + dependsOn: [listener, gatewayTargetGroup.listener_rule], } );