From 53e38998f77b4f6c64313f7a154f4ca2d8c11827 Mon Sep 17 00:00:00 2001 From: Georges Haidar Date: Wed, 2 Sep 2026 22:52:02 +0100 Subject: [PATCH] chore: retire xmcp runtime surface --- .speakeasy/out.openapi.yaml | 2 +- .../components/mintusersessionresponsebody.ts | 2 +- server/cmd/gram/start.go | 2 - server/design/usersessions/design.go | 2 +- server/gen/http/openapi3.yaml | 2 +- server/gen/http/user_sessions/client/types.go | 2 +- server/gen/http/user_sessions/server/types.go | 2 +- server/gen/user_sessions/service.go | 2 +- server/internal/attr/conventions.go | 4 +- .../killswitches/mcptoolexecution/resource.go | 9 +- server/internal/mcp/authnchallenge.go | 54 +- .../internal/mcp/authnchallenge_authorize.go | 8 +- server/internal/mcp/authnchallenge_consent.go | 12 +- .../mcp/authnchallenge_consent_action.go | 4 +- .../mcp/authnchallenge_consent_assets.go | 3 +- .../authnchallenge_consent_mcp_endpoint.go | 4 +- .../authnchallenge_consent_template_test.go | 4 +- .../internal/mcp/authnchallenge_firstparty.go | 17 +- .../internal/mcp/authnchallenge_register.go | 10 +- server/internal/mcp/authnchallenge_revoke.go | 8 +- server/internal/mcp/authnchallenge_test.go | 18 +- server/internal/mcp/authnchallenge_token.go | 14 +- .../internal/mcp/authnchallenge_well_known.go | 45 +- server/internal/mcp/impl.go | 39 +- .../internal/mcp/mcpmetrics/legacyfallback.go | 4 +- server/internal/mcp/mcpmetrics/metrics.go | 10 +- server/internal/mcp/mcpmetrics/surface.go | 6 +- .../internal/mcp/mcpversions/mcpversions.go | 8 +- server/internal/mcp/resolved_mcp_endpoint.go | 77 +- .../resolved_mcp_endpoint_internal_test.go | 31 +- server/internal/mcp/serve_meta.go | 2 +- server/internal/mcp/serve_meta_test.go | 28 +- server/internal/mcp/serveendpoint.go | 95 +- .../mcp/servepublic_mcpendpoint_test.go | 4 +- server/internal/mcp/servepublic_test.go | 16 + .../internal/mcp/servepublic_tunneled_test.go | 2 +- server/internal/mcpendpoints/resolve.go | 15 +- .../internal/middleware/chat_session_cors.go | 7 +- .../middleware/mcp_protocol_version.go | 15 +- .../middleware/mcp_protocol_version_test.go | 27 +- .../internal/middleware/mcp_security_test.go | 7 +- .../middleware/otel_public_endpoint_test.go | 3 - server/internal/oauth/wellknown/wellknown.go | 20 +- .../initialize_posthog_event_interceptor.go | 11 +- server/internal/remotemcp/proxy/proxy.go | 10 +- .../internal/remotemcp/proxy/reject_error.go | 6 +- server/internal/remotemcp/proxymanager.go | 6 +- .../request_otel_counter_interceptor.go | 7 +- ...sources_read_usage_tracking_interceptor.go | 2 +- .../tools_call_clickhouse_log_interceptor.go | 15 +- .../tools_call_usage_tracking_interceptor.go | 2 +- .../tools_list_posthog_event_interceptor.go | 14 +- server/internal/remotesessions/challenge.go | 44 +- .../internal/remotesessions/proxyregister.go | 1 - server/internal/shadowmcp/schema.go | 8 +- server/internal/usersessions/minthandler.go | 15 +- server/internal/xmcp/handler.go | 27 - .../xmcp/issuer_gated_mcp_server_test.go | 208 --- .../xmcp/serveoauth_integration_test.go | 348 ---- server/internal/xmcp/serveruntime_test.go | 1464 ----------------- server/internal/xmcp/service.go | 237 --- server/internal/xmcp/setup_test.go | 539 ------ server/internal/xmcp/wellknown.go | 75 - server/internal/xmcp/wellknown_test.go | 525 ------ 64 files changed, 296 insertions(+), 3914 deletions(-) delete mode 100644 server/internal/xmcp/handler.go delete mode 100644 server/internal/xmcp/issuer_gated_mcp_server_test.go delete mode 100644 server/internal/xmcp/serveoauth_integration_test.go delete mode 100644 server/internal/xmcp/serveruntime_test.go delete mode 100644 server/internal/xmcp/service.go delete mode 100644 server/internal/xmcp/setup_test.go delete mode 100644 server/internal/xmcp/wellknown.go delete mode 100644 server/internal/xmcp/wellknown_test.go diff --git a/.speakeasy/out.openapi.yaml b/.speakeasy/out.openapi.yaml index 733ae1dca93..03019544e00 100644 --- a/.speakeasy/out.openapi.yaml +++ b/.speakeasy/out.openapi.yaml @@ -75200,7 +75200,7 @@ components: properties: access_token: type: string - description: 'The minted user-session JWT. Send as `Authorization: Bearer` on MCP requests to the bound /mcp/{slug} (or /x/mcp/{slug}) surface.' + description: 'The minted user-session JWT. Send as `Authorization: Bearer` on MCP requests to the bound /mcp/{slug} surface.' expires_in: type: integer description: Lifetime of the access token in seconds. diff --git a/client/dashboard/src/sdk/src/models/components/mintusersessionresponsebody.ts b/client/dashboard/src/sdk/src/models/components/mintusersessionresponsebody.ts index 6cc97a0c830..6237e1772d9 100644 --- a/client/dashboard/src/sdk/src/models/components/mintusersessionresponsebody.ts +++ b/client/dashboard/src/sdk/src/models/components/mintusersessionresponsebody.ts @@ -10,7 +10,7 @@ import { SDKValidationError } from "../errors/sdkvalidationerror.js"; export type MintUserSessionResponseBody = { /** - * The minted user-session JWT. Send as `Authorization: Bearer` on MCP requests to the bound /mcp/{slug} (or /x/mcp/{slug}) surface. + * The minted user-session JWT. Send as `Authorization: Bearer` on MCP requests to the bound /mcp/{slug} surface. */ accessToken: string; /** diff --git a/server/cmd/gram/start.go b/server/cmd/gram/start.go index dc2b390db9a..20f66f1bca2 100644 --- a/server/cmd/gram/start.go +++ b/server/cmd/gram/start.go @@ -163,7 +163,6 @@ import ( userRepo "github.com/speakeasy-api/gram/server/internal/users/repo" "github.com/speakeasy-api/gram/server/internal/usersessions" "github.com/speakeasy-api/gram/server/internal/variations" - "github.com/speakeasy-api/gram/server/internal/xmcp" "github.com/speakeasy-api/gram/tunnel/route" ) @@ -1538,7 +1537,6 @@ func newStartCommand() *cli.Command { remotemcp.Attach(mux, remotemcp.NewService(logger, tracerProvider, db, sessionManager, encryptionClient, authzEngine, guardianPolicy, auditLogger, mcpServersService)) unproxiedmcp.Attach(mux, unproxiedmcp.NewService(logger, tracerProvider, db, sessionManager, authzEngine, guardianPolicy, auditLogger)) tunneledmcp.Attach(mux, tunneledmcp.NewService(logger, tracerProvider, db, sessionManager, authzEngine, auditLogger, route.NewRedis(redisClient), redisClient)) - xmcp.Attach(mux, xmcp.NewService(logger, db, encryptionClient, mcpService), mcpMetadataService) triggers.Attach(mux, triggers.NewService(logger, tracerProvider, db, sessionManager, authzEngine, triggerApp, auditLogger)) tools.Attach(mux, tools.NewService(logger, tracerProvider, db, sessionManager, authzEngine, platformFeatureChecker, assistantPlatformExtras)) resources.Attach(mux, resources.NewService(logger, tracerProvider, db, sessionManager, authzEngine)) diff --git a/server/design/usersessions/design.go b/server/design/usersessions/design.go index f384ab31a18..c6cb671f986 100644 --- a/server/design/usersessions/design.go +++ b/server/design/usersessions/design.go @@ -100,7 +100,7 @@ var _ = Service("userSessions", func() { }) Result(func() { - Attribute("access_token", String, "The minted user-session JWT. Send as `Authorization: Bearer` on MCP requests to the bound /mcp/{slug} (or /x/mcp/{slug}) surface.") + Attribute("access_token", String, "The minted user-session JWT. Send as `Authorization: Bearer` on MCP requests to the bound /mcp/{slug} surface.") Attribute("expires_in", Int, "Lifetime of the access token in seconds.") Required("access_token", "expires_in") }) diff --git a/server/gen/http/openapi3.yaml b/server/gen/http/openapi3.yaml index 1e0daa4ef8e..4d9542f24d8 100644 --- a/server/gen/http/openapi3.yaml +++ b/server/gen/http/openapi3.yaml @@ -76745,7 +76745,7 @@ components: properties: access_token: type: string - description: 'The minted user-session JWT. Send as `Authorization: Bearer` on MCP requests to the bound /mcp/{slug} (or /x/mcp/{slug}) surface.' + description: 'The minted user-session JWT. Send as `Authorization: Bearer` on MCP requests to the bound /mcp/{slug} surface.' expires_in: type: integer description: Lifetime of the access token in seconds. diff --git a/server/gen/http/user_sessions/client/types.go b/server/gen/http/user_sessions/client/types.go index 7f017b203d3..df21b4cbfa3 100644 --- a/server/gen/http/user_sessions/client/types.go +++ b/server/gen/http/user_sessions/client/types.go @@ -56,7 +56,7 @@ type ListFacetsResponseBody struct { // "mintUserSession" endpoint HTTP response body. type MintUserSessionResponseBody struct { // The minted user-session JWT. Send as `Authorization: Bearer` on MCP requests - // to the bound /mcp/{slug} (or /x/mcp/{slug}) surface. + // to the bound /mcp/{slug} surface. AccessToken *string `form:"access_token,omitempty" json:"access_token,omitempty" xml:"access_token,omitempty"` // Lifetime of the access token in seconds. ExpiresIn *int `form:"expires_in,omitempty" json:"expires_in,omitempty" xml:"expires_in,omitempty"` diff --git a/server/gen/http/user_sessions/server/types.go b/server/gen/http/user_sessions/server/types.go index 28328afd1df..060597d7cee 100644 --- a/server/gen/http/user_sessions/server/types.go +++ b/server/gen/http/user_sessions/server/types.go @@ -55,7 +55,7 @@ type ListFacetsResponseBody struct { // "mintUserSession" endpoint HTTP response body. type MintUserSessionResponseBody struct { // The minted user-session JWT. Send as `Authorization: Bearer` on MCP requests - // to the bound /mcp/{slug} (or /x/mcp/{slug}) surface. + // to the bound /mcp/{slug} surface. AccessToken string `form:"access_token" json:"access_token" xml:"access_token"` // Lifetime of the access token in seconds. ExpiresIn int `form:"expires_in" json:"expires_in" xml:"expires_in"` diff --git a/server/gen/user_sessions/service.go b/server/gen/user_sessions/service.go index bd91b4ccc66..696d92da694 100644 --- a/server/gen/user_sessions/service.go +++ b/server/gen/user_sessions/service.go @@ -131,7 +131,7 @@ type MintUserSessionPayload struct { // mintUserSession method. type MintUserSessionResult struct { // The minted user-session JWT. Send as `Authorization: Bearer` on MCP requests - // to the bound /mcp/{slug} (or /x/mcp/{slug}) surface. + // to the bound /mcp/{slug} surface. AccessToken string // Lifetime of the access token in seconds. ExpiresIn int diff --git a/server/internal/attr/conventions.go b/server/internal/attr/conventions.go index f737991152a..6bc6bfc2ad1 100644 --- a/server/internal/attr/conventions.go +++ b/server/internal/attr/conventions.go @@ -315,8 +315,8 @@ const ( // 2026-07-28), otherwise from the observed `initialize` response. McpNegotiatedProtocolVersionKey = attribute.Key("gram.mcp.negotiated_protocol_version") // McpSurfaceKey is the inbound MCP serving surface: "hosting" for the - // third-party-facing /mcp/{slug} and /x/mcp/{slug} paths (all backends), or - // "platform" for the assistant-token-only /platform/mcp/{toolsetSlug} path. + // third-party-facing /mcp/{slug} path, or "platform" for the + // assistant-token-only /platform/mcp/{toolsetSlug} path. McpSurfaceKey = attribute.Key("gram.mcp.surface") // McpKillswitchSurfaceKey is the kill-switch enforcement surface a covered // MCP tools/call reached: "hosted" or "private_proxy". diff --git a/server/internal/killswitches/mcptoolexecution/resource.go b/server/internal/killswitches/mcptoolexecution/resource.go index 28134d12b0d..7ed9d98aec4 100644 --- a/server/internal/killswitches/mcptoolexecution/resource.go +++ b/server/internal/killswitches/mcptoolexecution/resource.go @@ -23,11 +23,10 @@ var ErrServerNotInOrganization = errors.New("mcp server is not a live resource o // It must be populated from the request's resolved mcp_endpoint route, never // from a caller-provided identifier. type ServerSource struct { - // FrontingServerID is the fronting mcp_servers.id the route resolved for - // this request — the same row whether the request arrived via /mcp/{slug} - // or /x/mcp/{slug}, and regardless of the toolset, remote, or tunneled - // backend behind it. Invalid marks a serving mode with no fronting server - // (the legacy toolset-only fallback), which is deliberately unsupported. + // FrontingServerID identifies the mcp_servers row that fronted this request, + // regardless of the toolset, remote, or tunneled backend behind it. Invalid + // marks a serving mode with no fronting server (the legacy toolset-only + // fallback), which is deliberately unsupported. FrontingServerID uuid.NullUUID } diff --git a/server/internal/mcp/authnchallenge.go b/server/internal/mcp/authnchallenge.go index 77a65972891..d73fbd491b3 100644 --- a/server/internal/mcp/authnchallenge.go +++ b/server/internal/mcp/authnchallenge.go @@ -70,10 +70,9 @@ type EndpointRef struct { // the server default origin until the 10-min challenge TTL elapses. BaseURL string `json:"base_url,omitempty"` - // McpServerID, when valid, identifies the mcp_servers row that owns - // this challenge. Populated by /x/mcp callers whose endpoint - // addresses resolve through mcp_endpoints → mcp_servers; zero for - // /mcp callers. + // McpServerID, when valid, identifies the mcp_servers row that owns this + // challenge. It is populated when the endpoint resolves through + // mcp_endpoints → mcp_servers. McpServerID uuid.NullUUID `json:"mcp_server_id"` // MetaMcpServerID, when valid, identifies the meta_mcp_servers row that @@ -83,14 +82,11 @@ type EndpointRef struct { // challenge before it existed. MetaMcpServerID uuid.NullUUID `json:"meta_mcp_server_id,omitzero"` - // Path of a toolset-backed endpoint. Set for /mcp and toolset-backed - // /x/mcp challenges. + // McpSlug is the public endpoint address. McpSlug string `json:"mcp_slug"` - // RouteBase is the URL path prefix the challenge was minted under - // ("mcp" or "x/mcp"). Empty value is treated as "mcp" by callers for - // backward compatibility with states minted before this field was - // added. + // RouteBase is the inbound URL path prefix. Empty values from older cached + // challenges resolve to "mcp". RouteBase string `json:"route_base,omitempty"` } @@ -457,12 +453,10 @@ func WriteAuthenticateChallenge(w http.ResponseWriter, protectedResourceURL, mes return oops.E(oops.CodeUnauthorized, nil, "%s", message) } -// BaseURLForRequest returns the public base URL the runtime request was -// addressed at — the custom domain when one is bound to the request -// context, the server's default origin otherwise. Exposed so /x/mcp -// callers building post-resolution OAuth URLs see the same origin /mcp -// callers do. -func (s *Service) BaseURLForRequest(r *http.Request) string { +// baseURLForRequest returns the public base URL the runtime request was +// addressed at: the custom domain when one is bound to the request context, +// the server's default origin otherwise. +func (s *Service) baseURLForRequest(r *http.Request) string { if domainCtx := customdomains.FromContext(r.Context()); domainCtx != nil { return fmt.Sprintf("https://%s", domainCtx.Domain) } @@ -477,24 +471,14 @@ type issuerGateAuthentication struct { subject urn.SessionSubject } -// authenticateIssuerGate runs the issuer-gated authentication branch shared by -// the toolset-keyed (/mcp) and mcp_server-keyed (/x/mcp) MCP runtime -// paths. It validates the bearer token as a user-session JWT and falls back -// to an assistant-runtime JWT scoped to the endpoint's project. Upstream -// remote-session credentials are deliberately resolved by a separate step so -// hosted tool calls can evaluate kill switches first. +// authenticateIssuerGate validates issuer-gated requests as user-session JWTs +// and falls back to an assistant-runtime JWT scoped to the endpoint's project. +// Upstream remote-session credentials are resolved separately so hosted tool +// calls can evaluate kill switches first. // -// On success it returns the stamped request context, the authenticated subject -// needed for deferred credential resolution, and the caller's tool selection. -// On failure it writes a 401 + WWW-Authenticate and returns the CodeUnauthorized -// error from WriteAuthenticateChallenge. The resource_metadata URL is built -// from baseURL + endpoint.RouteBase + -// endpoint.Slug so a /x/mcp request gets pointed at /x/mcp's -// protected-resource metadata, not /mcp's. -// -// /x/mcp uses this to gate requests on mcp_servers.user_session_issuer_id -// before dispatching to its remote backend or delegating to -// ServeToolsetResolved with the gate skipped. +// On success it returns the stamped request context, authenticated subject, +// and caller tool selection. On failure it writes a 401 + WWW-Authenticate and +// returns the CodeUnauthorized error from WriteAuthenticateChallenge. func (s *Service) authenticateIssuerGate( ctx context.Context, w http.ResponseWriter, @@ -651,9 +635,7 @@ var errToolsetEndpointMismatch = errors.New("authn challenge endpoint does not m // config today, but any future consumer must either route through here or // tolerate an unstamped endpoint, which reads as an unset mode. // -// Exported so /x/mcp's [Service.buildResolvedMcpEndpoint] can include -// the live-FK check in the same place as the -// NewResolvedMcpEndpointFromMcpServer construction. +// Exported constructors use this helper to apply the same live-FK check. func (s *Service) RequireUserSessionIssuer(ctx context.Context, endpoint *ResolvedMcpEndpoint) error { issuer, err := usersessions_repo.New(s.db).GetUserSessionIssuerByID(ctx, usersessions_repo.GetUserSessionIssuerByIDParams{ ID: endpoint.UserSessionIssuerID, diff --git a/server/internal/mcp/authnchallenge_authorize.go b/server/internal/mcp/authnchallenge_authorize.go index 60c6f6e2983..764bdebf20c 100644 --- a/server/internal/mcp/authnchallenge_authorize.go +++ b/server/internal/mcp/authnchallenge_authorize.go @@ -47,16 +47,14 @@ func (s *Service) HandleAuthorize(w http.ResponseWriter, r *http.Request) error return oops.E(oops.CodeBadRequest, nil, "an mcp slug must be provided").LogError(ctx, s.logger) } logger := s.logger.With(attr.SlogToolsetMCPSlug(mcpSlug)) - endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug, "mcp") + endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug) if err != nil { return err } return s.ServeAuthorize(w, r, endpoint) } -// ServeAuthorize is the post-resolution entry point for the OAuth 2.1 -// authorize endpoint, shared by /mcp's HandleAuthorize (toolset-keyed) -// and /x/mcp's mcp_endpoint-keyed route registration. +// ServeAuthorize handles the post-resolution authorization endpoint. func (s *Service) ServeAuthorize(w http.ResponseWriter, r *http.Request, endpoint *ResolvedMcpEndpoint) error { ctx := r.Context() logger := endpoint.LogWith(s.logger) @@ -123,7 +121,7 @@ func (s *Service) ServeAuthorize(w http.ResponseWriter, r *http.Request, endpoin // definition — the challenge below snapshots it — and it is what the AS // metadata document advertises as the issuer, so both the error redirect // below and every response built later in the flow agree on it. - baseURL := s.BaseURLForRequest(r) + baseURL := s.baseURLForRequest(r) // The endpoint's canonical URI at the address this request arrived on. One // value serves three contracts: the RFC 9207 `iss` on every authorization diff --git a/server/internal/mcp/authnchallenge_consent.go b/server/internal/mcp/authnchallenge_consent.go index 29dddcd0605..11f6095acf6 100644 --- a/server/internal/mcp/authnchallenge_consent.go +++ b/server/internal/mcp/authnchallenge_consent.go @@ -69,9 +69,7 @@ var consentScriptHash = func() string { return hex.EncodeToString(sum[:])[:8] }() -// consentScriptURL is the path the consent template loads the script from. -// Hardcoded to the /mcp surface (like the install-page script) so the -// /x/mcp surface reuses the same route rather than registering its own. +// consentScriptURL is the canonical consent-page asset route. var consentScriptURL = "/mcp/consent-page-" + consentScriptHash + ".js" // remoteSetHashEmpty is the SHA-256 of an empty remote-set, used by the @@ -304,7 +302,7 @@ func (s *Service) HandleConsent(w http.ResponseWriter, r *http.Request) error { return oops.E(oops.CodeBadRequest, nil, "an mcp slug must be provided").LogError(ctx, s.logger) } logger := s.logger.With(attr.SlogToolsetMCPSlug(mcpSlug)) - endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug, "mcp") + endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug) if err != nil { return err } @@ -329,9 +327,7 @@ func (s *Service) ServeConsentScript(w http.ResponseWriter, r *http.Request) err return nil } -// ServeConsent is the post-resolution entry point for the consent UI -// (GET) and consent POST handlers, shared by /mcp's HandleConsent -// (toolset-keyed) and /x/mcp's mcp_endpoint-keyed route registration. +// ServeConsent handles the post-resolution consent GET and POST endpoints. func (s *Service) ServeConsent(w http.ResponseWriter, r *http.Request, endpoint *ResolvedMcpEndpoint) error { switch r.Method { case http.MethodGet: @@ -574,7 +570,7 @@ func (s *Service) serveConsentPost(w http.ResponseWriter, r *http.Request, endpo // return leg re-enters consent on the platform origin, so a POST carrying // a custom-domain context can still be completing a flow the client // recorded under a different origin (or vice versa). - issuer, err := endpoint.RootURL(challengeState.mintOriginOr(s.BaseURLForRequest(r))) + issuer, err := endpoint.RootURL(challengeState.mintOriginOr(s.baseURLForRequest(r))) if err != nil { s.metrics.RecordOAuthFlowFailed(ctx, issuerID, mcpSlug, mcpmetrics.OAuthFlowStageConsent) return oops.E(oops.CodeUnexpected, err, "build authorization response issuer").LogError(ctx, logger) diff --git a/server/internal/mcp/authnchallenge_consent_action.go b/server/internal/mcp/authnchallenge_consent_action.go index 7850216d1be..04a37a1a970 100644 --- a/server/internal/mcp/authnchallenge_consent_action.go +++ b/server/internal/mcp/authnchallenge_consent_action.go @@ -35,14 +35,14 @@ func (s *Service) HandleConsentAction(w http.ResponseWriter, r *http.Request) er return oops.E(oops.CodeBadRequest, nil, "an mcp slug must be provided").LogError(ctx, s.logger) } logger := s.logger.With(attr.SlogToolsetMCPSlug(mcpSlug)) - endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug, "mcp") + endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug) if err != nil { return err } return s.ServeConsentAction(w, r, endpoint) } -// ServeConsentAction is the post-resolution handler, shared with /x/mcp. +// ServeConsentAction handles post-resolution consent submissions. func (s *Service) ServeConsentAction(w http.ResponseWriter, r *http.Request, endpoint *ResolvedMcpEndpoint) error { ctx := r.Context() logger := endpoint.LogWith(s.logger) diff --git a/server/internal/mcp/authnchallenge_consent_assets.go b/server/internal/mcp/authnchallenge_consent_assets.go index b8a0348d1ca..c5e1f8f22d5 100644 --- a/server/internal/mcp/authnchallenge_consent_assets.go +++ b/server/internal/mcp/authnchallenge_consent_assets.go @@ -28,8 +28,7 @@ var consentToolsScriptHash = func() string { return hex.EncodeToString(sum[:])[:8] }() -// consentToolsScriptURL is the path the consent template loads the island -// from. Hardcoded to the /mcp surface so /x/mcp pages reuse the same route. +// consentToolsScriptURL is the path the consent template loads the island from. var consentToolsScriptURL = "/mcp/consent-tools-" + consentToolsScriptHash + ".js" // ServeConsentToolsScript serves the island bundle with immutable cache diff --git a/server/internal/mcp/authnchallenge_consent_mcp_endpoint.go b/server/internal/mcp/authnchallenge_consent_mcp_endpoint.go index 909c52bcbf5..1c00f908622 100644 --- a/server/internal/mcp/authnchallenge_consent_mcp_endpoint.go +++ b/server/internal/mcp/authnchallenge_consent_mcp_endpoint.go @@ -76,14 +76,14 @@ func (s *Service) HandleConsentMCP(w http.ResponseWriter, r *http.Request) error return oops.E(oops.CodeBadRequest, nil, "an mcp slug must be provided").LogError(ctx, s.logger) } logger := s.logger.With(attr.SlogToolsetMCPSlug(mcpSlug)) - endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug, "mcp") + endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug) if err != nil { return err } return s.ServeConsentMCP(w, r, endpoint) } -// ServeConsentMCP is the post-resolution handler, shared with /x/mcp. +// ServeConsentMCP handles the consent-scoped MCP transport. func (s *Service) ServeConsentMCP(w http.ResponseWriter, r *http.Request, endpoint *ResolvedMcpEndpoint) error { ctx := r.Context() logger := endpoint.LogWith(s.logger) diff --git a/server/internal/mcp/authnchallenge_consent_template_test.go b/server/internal/mcp/authnchallenge_consent_template_test.go index 68149801d9a..a1bbcad2631 100644 --- a/server/internal/mcp/authnchallenge_consent_template_test.go +++ b/server/internal/mcp/authnchallenge_consent_template_test.go @@ -22,7 +22,7 @@ func TestConsentTemplateCompletedFirstPartyConnectionAutoCloses(t *testing.T) { err := consentTemplate.Execute(&page, consentTemplateData{ ClientName: "Gram", MCPSlug: "example", - MCPRouteBase: "x/mcp", + MCPRouteBase: "mcp", State: "state", CSRFToken: "csrf", SubjectDisplay: "user@example.com", @@ -58,7 +58,7 @@ func TestConsentTemplateIncompleteFirstPartyConnectionStaysOpen(t *testing.T) { err := consentTemplate.Execute(&page, consentTemplateData{ ClientName: "Gram", MCPSlug: "example", - MCPRouteBase: "x/mcp", + MCPRouteBase: "mcp", State: "state", CSRFToken: "csrf", SubjectDisplay: "user@example.com", diff --git a/server/internal/mcp/authnchallenge_firstparty.go b/server/internal/mcp/authnchallenge_firstparty.go index 2e7b7003483..2ebc6a3094f 100644 --- a/server/internal/mcp/authnchallenge_firstparty.go +++ b/server/internal/mcp/authnchallenge_firstparty.go @@ -13,12 +13,8 @@ import ( "github.com/speakeasy-api/gram/server/internal/oops" ) -// HandleFirstPartyConnect is the chi handler at -// `GET /mcp/{mcpSlug}/connect/first-party` on the toolset-keyed surface. It -// resolves the slug to a `/mcp`-keyed ResolvedMcpEndpoint and delegates to -// ServeFirstPartyConnect — the dashboard's entry point for linking an -// issuer-gated toolset's upstream sessions. /x/mcp registers the equivalent -// via its mcp_endpoint-keyed adapter (see xmcp.Service.handleFirstPartyConnect). +// HandleFirstPartyConnect is the dashboard entry point for linking an +// issuer-gated endpoint's upstream sessions. func (s *Service) HandleFirstPartyConnect(w http.ResponseWriter, r *http.Request) error { ctx := r.Context() mcpSlug := chi.URLParam(r, "mcpSlug") @@ -26,7 +22,7 @@ func (s *Service) HandleFirstPartyConnect(w http.ResponseWriter, r *http.Request return oops.E(oops.CodeBadRequest, nil, "an mcp slug must be provided").LogError(ctx, s.logger) } logger := s.logger.With(attr.SlogToolsetMCPSlug(mcpSlug)) - endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug, "mcp") + endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug) if err != nil { return err } @@ -35,9 +31,8 @@ func (s *Service) HandleFirstPartyConnect(w http.ResponseWriter, r *http.Request // ServeFirstPartyConnect is the dashboard's entry point for establishing the // upstream remote_sessions an issuer-gated MCP server needs. It mints a -// first-party authn challenge and bounces through the gram server's own IDP -// login — the same flow a real MCP client runs via /x/mcp/{slug}/authorize — -// rather than borrowing the dashboard's gram_session. +// first-party authn challenge and bounces through the Gram server's own IDP +// login rather than borrowing the dashboard's gram_session. // // This is deliberately decoupled from the dashboard session: the subject is // stamped onto the challenge by HandleIDPCallback from authoritative IDP @@ -58,7 +53,7 @@ func (s *Service) ServeFirstPartyConnect(w http.ResponseWriter, r *http.Request, return oops.E(oops.CodeUnexpected, err, "generate consent csrf token").LogError(ctx, logger) } - baseURL := s.BaseURLForRequest(r) + baseURL := s.baseURLForRequest(r) flowID := uuid.NewString() challengeID := uuid.NewString() challengeState := AuthnChallengeState{ diff --git a/server/internal/mcp/authnchallenge_register.go b/server/internal/mcp/authnchallenge_register.go index a821190075d..85098d3692e 100644 --- a/server/internal/mcp/authnchallenge_register.go +++ b/server/internal/mcp/authnchallenge_register.go @@ -66,18 +66,16 @@ func (s *Service) HandleRegister(w http.ResponseWriter, r *http.Request) error { return oops.E(oops.CodeBadRequest, nil, "an mcp slug must be provided").LogError(ctx, s.logger) } logger := s.logger.With(attr.SlogToolsetMCPSlug(mcpSlug)) - endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug, "mcp") + endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug) if err != nil { return err } return s.ServeRegister(w, r, endpoint) } -// ServeRegister implements RFC 7591 Dynamic Client Registration for -// issuer-gated MCP servers. Post-resolution entry point shared by -// /mcp's HandleRegister (toolset-keyed) and /x/mcp's mcp_endpoint-keyed -// route registration. Public endpoint (no caller auth); the issuer's -// metadata document advertises this URL via `registration_endpoint`. +// ServeRegister handles dynamic client registration for a resolved +// issuer-gated endpoint. It is public; the issuer metadata document advertises +// this URL via registration_endpoint. // // Generated client_secret is returned plaintext exactly once; only its // bcrypt hash is persisted in user_session_clients.client_secret_hash. diff --git a/server/internal/mcp/authnchallenge_revoke.go b/server/internal/mcp/authnchallenge_revoke.go index b65b5a69633..b4d7122f0ec 100644 --- a/server/internal/mcp/authnchallenge_revoke.go +++ b/server/internal/mcp/authnchallenge_revoke.go @@ -30,16 +30,14 @@ func (s *Service) HandleRevoke(w http.ResponseWriter, r *http.Request) error { return oops.E(oops.CodeBadRequest, nil, "an mcp slug must be provided").LogError(ctx, s.logger) } logger := s.logger.With(attr.SlogToolsetMCPSlug(mcpSlug)) - endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug, "mcp") + endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug) if err != nil { return err } return s.ServeRevoke(w, r, endpoint) } -// ServeRevoke implements RFC 7009 token revocation. Post-resolution entry -// point shared by /mcp's HandleRevoke (toolset-keyed) and /x/mcp's -// mcp_endpoint-keyed route registration. +// ServeRevoke implements RFC 7009 token revocation for a resolved endpoint. // // Per RFC 7009 §2.2: the response is HTTP 200 unconditionally on success or // when the token is unknown / already revoked / was never valid -- the spec @@ -93,7 +91,7 @@ func (s *Service) ServeRevoke(w http.ResponseWriter, r *http.Request, endpoint * // deliberately NOT applied here is the CIMD admission `disabled` check: // revocation is a de-escalation, and a client an operator has just // de-admitted should still be able to kill its own outstanding tokens. - if reason := s.authenticateOAuthClient(ctx, logger, endpoint, clientAssertionAtRevoke, &clientRow, creds, s.BaseURLForRequest(r)); reason != "" { + if reason := s.authenticateOAuthClient(ctx, logger, endpoint, clientAssertionAtRevoke, &clientRow, creds, s.baseURLForRequest(r)); reason != "" { logOAuthClientCredentialEvent(ctx, logger, r, "oauth revoke client authentication rejected", clientID, presentedAuthMethod, "", reason) return writeTokenError(ctx, w, logger, http.StatusUnauthorized, "invalid_client", clientAuthFailureDescription) } diff --git a/server/internal/mcp/authnchallenge_test.go b/server/internal/mcp/authnchallenge_test.go index bd91e26a4b8..96efa5eeda6 100644 --- a/server/internal/mcp/authnchallenge_test.go +++ b/server/internal/mcp/authnchallenge_test.go @@ -1364,7 +1364,7 @@ func seedModernConsentChallenge( ID: stateID, UserSessionIssuerID: issuerID, Endpoint: mcp.EndpointRef{ - RouteBase: "x/mcp", + RouteBase: "mcp", McpSlug: endpointSlug, McpServerID: uuid.NullUUID{UUID: mcpServerID, Valid: true}, }, @@ -1401,9 +1401,9 @@ func consentGetPage(t *testing.T, ti *testInstance, mcpSlug, stateID string) str func modernConsentGetPage(t *testing.T, ctx context.Context, ti *testInstance, endpointSlug, stateID string) string { t.Helper() - endpoint, err := ti.service.LoadResolvedMcpEndpointBySlug(ctx, ti.logger, endpointSlug, "x/mcp") + endpoint, err := ti.service.LoadResolvedMcpEndpointBySlug(ctx, ti.logger, endpointSlug) require.NoError(t, err) - req := httptest.NewRequest(http.MethodGet, "/x/mcp/"+endpointSlug+"/connect?state="+stateID, nil).WithContext(ctx) + req := httptest.NewRequest(http.MethodGet, "/mcp/"+endpointSlug+"/connect?state="+stateID, nil).WithContext(ctx) w := httptest.NewRecorder() require.NoError(t, ti.service.ServeConsent(w, req, endpoint)) require.Equal(t, http.StatusOK, w.Code) @@ -1597,10 +1597,10 @@ func TestHandleConsentMCP_RemoteToolsListStripsOutputSchema(t *testing.T) { mcpServer.ID, endpointSlug, ) - endpoint, err := ti.service.LoadResolvedMcpEndpointBySlug(ctx, ti.logger, endpointSlug, "x/mcp") + endpoint, err := ti.service.LoadResolvedMcpEndpointBySlug(ctx, ti.logger, endpointSlug) require.NoError(t, err) - req := httptest.NewRequest(http.MethodPost, "/x/mcp/"+endpointSlug+"/connect/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)) + req := httptest.NewRequest(http.MethodPost, "/mcp/"+endpointSlug+"/connect/mcp", strings.NewReader(`{"jsonrpc":"2.0","id":1,"method":"tools/list"}`)) req.Header.Set("Accept", "application/json, text/event-stream") req.Header.Set("Content-Type", "application/json") req.Header.Set("Gram-Consent-State", stateID) @@ -1634,7 +1634,7 @@ func TestHandleConsentPost_FilteringOnWithoutInventoryConflicts(t *testing.T) { endpointSlug := "modern-no-inventory-" + uuid.NewString() mcpServer := createToolsetMcpEndpoint(t, ctx, ti.conn, toolset.ProjectID, toolset.ID, endpointSlug, "public", uuid.NullUUID{}, issuer.ID) stateID, csrfToken := seedModernConsentChallenge(t, ctx, ti, issuer.ID, client, mcpServer.ID, endpointSlug) - endpoint, err := ti.service.LoadResolvedMcpEndpointBySlug(ctx, ti.logger, endpointSlug, "x/mcp") + endpoint, err := ti.service.LoadResolvedMcpEndpointBySlug(ctx, ti.logger, endpointSlug) require.NoError(t, err) form := url.Values{} @@ -1643,7 +1643,7 @@ func TestHandleConsentPost_FilteringOnWithoutInventoryConflicts(t *testing.T) { form.Set("action", "approve") form.Set("tool_filtering", "on") form.Set("tool_selection_mode", "tools") - req := httptest.NewRequest(http.MethodPost, "/x/mcp/"+endpointSlug+"/connect", strings.NewReader(form.Encode())) + req := httptest.NewRequest(http.MethodPost, "/mcp/"+endpointSlug+"/connect", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req = req.WithContext(ctx) @@ -1698,7 +1698,7 @@ func TestHandleConsentPost_ApproveWithToolFilteringBindsSelection(t *testing.T) endpointSlug := "modern-filtered-" + uuid.NewString() mcpServer := createToolsetMcpEndpoint(t, ctx, ti.conn, toolset.ProjectID, toolset.ID, endpointSlug, "public", uuid.NullUUID{}, issuer.ID) stateID, csrfToken := seedModernConsentChallenge(t, ctx, ti, issuer.ID, client, mcpServer.ID, endpointSlug) - endpoint, err := ti.service.LoadResolvedMcpEndpointBySlug(ctx, ti.logger, endpointSlug, "x/mcp") + endpoint, err := ti.service.LoadResolvedMcpEndpointBySlug(ctx, ti.logger, endpointSlug) require.NoError(t, err) attempt := hydrateConsentInventory(t, ctx, ti, endpoint, stateID, csrfToken) @@ -1710,7 +1710,7 @@ func TestHandleConsentPost_ApproveWithToolFilteringBindsSelection(t *testing.T) form.Set("tool_inventory_id", attempt) form.Set("tool_filtering", "on") form.Add("tools", "not-in-inventory") - req := httptest.NewRequest(http.MethodPost, "/x/mcp/"+endpointSlug+"/connect", strings.NewReader(form.Encode())) + req := httptest.NewRequest(http.MethodPost, "/mcp/"+endpointSlug+"/connect", strings.NewReader(form.Encode())) req.Header.Set("Content-Type", "application/x-www-form-urlencoded") req = req.WithContext(ctx) diff --git a/server/internal/mcp/authnchallenge_token.go b/server/internal/mcp/authnchallenge_token.go index 0f9c340f7bc..276a43fb107 100644 --- a/server/internal/mcp/authnchallenge_token.go +++ b/server/internal/mcp/authnchallenge_token.go @@ -133,20 +133,16 @@ func (s *Service) HandleToken(w http.ResponseWriter, r *http.Request) error { return oops.E(oops.CodeBadRequest, nil, "an mcp slug must be provided").LogError(ctx, s.logger) } logger := s.logger.With(attr.SlogToolsetMCPSlug(mcpSlug)) - endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug, "mcp") + endpoint, err := s.LoadResolvedMcpEndpointBySlug(ctx, logger, mcpSlug) if err != nil { return err } return s.ServeToken(w, r, endpoint) } -// ServeToken is the post-resolution entry point for the OAuth 2.1 -// token endpoint, shared by /mcp's HandleToken (toolset-keyed) and -// /x/mcp's mcp_endpoint-keyed route registration. Performs the common -// upfront work — parse form, authenticate the client — then dispatches -// on grant_type to handleTokenAuthorizationCodeGrant or -// handleTokenRefreshTokenGrant. Both grant handlers mint and persist the -// RFC 6749 §5.1 response through mintSession. +// ServeToken handles token requests for a resolved endpoint. It parses and +// authenticates the client before dispatching on grant_type; both grant +// handlers mint and persist the user-session JWT. func (s *Service) ServeToken(w http.ResponseWriter, r *http.Request, endpoint *ResolvedMcpEndpoint) error { ctx := r.Context() @@ -170,7 +166,7 @@ func (s *Service) ServeToken(w http.ResponseWriter, r *http.Request, endpoint *R // the two sides of the contract stay aligned across custom domains. // Computed before client authentication because an assertion's aud is // checked against URLs derived from it. - baseURL := s.BaseURLForRequest(r) + baseURL := s.baseURLForRequest(r) // lookupClientOnly: any CIMD row was persisted at authorize time, and // mid-flow token legs must keep working even if the issuer's admission // policy changes between legs. diff --git a/server/internal/mcp/authnchallenge_well_known.go b/server/internal/mcp/authnchallenge_well_known.go index d5f7e242942..9aa54fa77b1 100644 --- a/server/internal/mcp/authnchallenge_well_known.go +++ b/server/internal/mcp/authnchallenge_well_known.go @@ -159,7 +159,7 @@ func (s *Service) HandleGetProtectedResource(w http.ResponseWriter, r *http.Requ return s.ServeGetProtectedResource(w, r, endpoint) } - resourceURL, err := url.JoinPath(s.BaseURLForRequest(r), "mcp", mcpSlug) + resourceURL, err := url.JoinPath(s.baseURLForRequest(r), "mcp", mcpSlug) if err != nil { return oops.E(oops.CodeUnexpected, err, "build legacy resource URL").LogError(ctx, s.logger) } @@ -218,7 +218,7 @@ func (s *Service) HandleGetAuthorizationServer(w http.ResponseWriter, r *http.Re // which equals the requested slug on this fallback path. The resource URL // mirrors HandleGetProtectedResource so the served issuer matches the // protected-resource metadata's authorization_servers entry. - resourceURL, err := url.JoinPath(s.BaseURLForRequest(r), "mcp", mcpSlug) + resourceURL, err := url.JoinPath(s.baseURLForRequest(r), "mcp", mcpSlug) if err != nil { return oops.E(oops.CodeUnexpected, err, "build legacy resource URL").LogError(ctx, s.logger) } @@ -226,9 +226,7 @@ func (s *Service) HandleGetAuthorizationServer(w http.ResponseWriter, r *http.Re } // ServeWellKnownProtectedResourceForServer serves RFC 9728 protected-resource -// metadata for an already-resolved (mcp_endpoint, mcp_server) pair. It is the -// single per-backend dispatch shared by the /mcp (routeBase "mcp") and /x/mcp -// (routeBase "x/mcp") well-known surfaces: +// metadata for an already-resolved (mcp_endpoint, mcp_server) pair: // // - Issuer-gated (any backend): emit the Gram-hosted metadata shape rooted // at the resolved endpoint's URL on routeBase's surface. @@ -270,7 +268,7 @@ func (s *Service) ServeWellKnownProtectedResourceForServer( if err != nil { return err } - resourceURL, err := url.JoinPath(s.BaseURLForRequest(r), routeBase, mcpEndpoint.Slug) + resourceURL, err := url.JoinPath(s.baseURLForRequest(r), routeBase, mcpEndpoint.Slug) if err != nil { return oops.E(oops.CodeUnexpected, err, "build resource URL").LogError(ctx, logger) } @@ -316,14 +314,13 @@ func (s *Service) ServeWellKnownAuthorizationServerForServer( if err != nil { return err } - // The OAuth slug and the resource URL are both keyed on the endpoint - // the request arrived at, so a hosted server can carry several - // endpoints and none of them has to equal toolsets.mcp_slug. The - // resource URL mirrors ServeWellKnownProtectedResourceForServer so the - // served issuer matches the protected-resource metadata's - // authorization_servers entry. + // The OAuth slug and resource URL are both keyed on the endpoint the + // request arrived at, so a hosted server can carry several endpoints + // and none has to equal toolsets.mcp_slug. The resource URL mirrors + // ServeWellKnownProtectedResourceForServer so the served issuer matches + // the protected-resource metadata's authorization_servers entry. oauthSlug := mcpEndpoint.Slug - resourceURL, err := url.JoinPath(s.BaseURLForRequest(r), routeBase, mcpEndpoint.Slug) + resourceURL, err := url.JoinPath(s.baseURLForRequest(r), routeBase, mcpEndpoint.Slug) if err != nil { return oops.E(oops.CodeUnexpected, err, "build resource URL").LogError(ctx, logger) } @@ -373,7 +370,7 @@ func (s *Service) serveLegacyToolsetProtectedResource(ctx context.Context, w htt // keys the emitted issuer / endpoint URLs onto the legacy /oauth/{slug} // surface. func (s *Service) serveLegacyToolsetAuthorizationServer(ctx context.Context, w http.ResponseWriter, r *http.Request, logger *slog.Logger, toolset *toolsets_repo.Toolset, oauthSlug, resourceURL string) error { - result, err := wellknown.ResolveOAuthServerMetadataFromToolset(ctx, logger, s.db, s.oauthRepo, &s.toolsetCache, toolset, s.BaseURLForRequest(r), oauthSlug, resourceURL) + result, err := wellknown.ResolveOAuthServerMetadataFromToolset(ctx, logger, s.db, s.oauthRepo, &s.toolsetCache, toolset, s.baseURLForRequest(r), oauthSlug, resourceURL) if err != nil { return oops.E(oops.CodeUnexpected, err, "failed to resolve OAuth server metadata").LogError(ctx, logger) } @@ -405,15 +402,12 @@ func (s *Service) serveLegacyToolsetAuthorizationServer(ctx context.Context, w h return writeOAuthServerMetadataResponse(ctx, logger, w, r, result) } -// ServeGetProtectedResource is the post-resolution entry point for the -// RFC 9728 protected-resource metadata response, shared by /mcp's -// HandleGetProtectedResource (toolset-keyed) and /x/mcp's mcp_endpoint- -// keyed route registration. Emits the issuer-gated metadata shape; the -// legacy non-issuer-gated fallback stays in HandleGetProtectedResource -// because it depends on the toolsets row directly. +// ServeGetProtectedResource emits RFC 9728 protected-resource metadata for an +// already-resolved endpoint. The legacy non-issuer-gated fallback stays in +// HandleGetProtectedResource because it depends on the toolsets row directly. func (s *Service) ServeGetProtectedResource(w http.ResponseWriter, r *http.Request, endpoint *ResolvedMcpEndpoint) error { ctx := r.Context() - baseURL := s.BaseURLForRequest(r) + baseURL := s.baseURLForRequest(r) resource, err := endpoint.RootURL(baseURL) if err != nil { return oops.E(oops.CodeUnexpected, err, "build resource URL").LogError(ctx, s.logger) @@ -426,15 +420,12 @@ func (s *Service) ServeGetProtectedResource(w http.ResponseWriter, r *http.Reque }) } -// ServeGetAuthorizationServer is the post-resolution entry point for the -// RFC 8414 authorization-server metadata response, shared by /mcp's -// HandleGetAuthorizationServer (toolset-keyed) and /x/mcp's -// mcp_endpoint-keyed route registration. Emits the issuer-gated -// metadata shape; the legacy non-issuer-gated fallback stays in +// ServeGetAuthorizationServer emits RFC 8414 authorization-server metadata for +// an already-resolved endpoint. The legacy non-issuer-gated fallback stays in // HandleGetAuthorizationServer. func (s *Service) ServeGetAuthorizationServer(w http.ResponseWriter, r *http.Request, endpoint *ResolvedMcpEndpoint) error { ctx := r.Context() - baseURL := s.BaseURLForRequest(r) + baseURL := s.baseURLForRequest(r) urls, err := endpoint.AuthorizationServerURLs(baseURL) if err != nil { return oops.E(oops.CodeUnexpected, err, "build OAuth server URLs").LogError(ctx, s.logger) diff --git a/server/internal/mcp/impl.go b/server/internal/mcp/impl.go index 34a1f65205d..260979be226 100644 --- a/server/internal/mcp/impl.go +++ b/server/internal/mcp/impl.go @@ -179,10 +179,9 @@ type Service struct { // interactive /connect cards and the /remote_login_callback handler. remoteChallengeMgr *remotesessions.ChallengeManager // remoteProxyManager builds configured remotemcp proxies wired with the - // MCP-aware interceptor stack. Only consulted by ServeMCPEndpoint's - // remote-backed branch; may be nil in non-HTTP contexts (e.g. the - // Temporal worker, which constructs *Service for its programmatic - // helpers but never serves a runtime request). + // MCP-aware interceptor stack. It may be nil in non-HTTP contexts (e.g. the + // Temporal worker, which constructs *Service for its programmatic helpers + // but never serves a runtime request). remoteProxyManager *remotemcp.ProxyManager tunnelManager *tunnelManager // Nil when no Redis was wired; every public tunneled request then fails closed. @@ -546,11 +545,8 @@ func Attach(mux goahttp.Muxer, service *Service, metadataService *mcpmetadata.Se o11y.AttachHandler(mux, "GET", PublicServerRoute+"/remote_login_callback", oops.ErrHandle(service.logger, service.HandleRemoteLoginCallback).ServeHTTP) } -// HandleRemoteLoginCallback is the chi handler at -// `GET /mcp/remote_login_callback` (plus the legacy per-slug variant). Thin -// passthrough to remotesessions.ChallengeManager so /x/mcp can reuse the -// same handler via the public method instead of reaching into the -// unexported manager field. +// HandleRemoteLoginCallback delegates the canonical and legacy per-slug +// callback routes to the remote-session challenge manager. func (s *Service) HandleRemoteLoginCallback(w http.ResponseWriter, r *http.Request) error { return s.remoteChallengeMgr.HandleRemoteLoginCallback(w, r) //nolint:wrapcheck // thin passthrough; the inner handler already writes the HTTP response. } @@ -754,13 +750,12 @@ func writeOAuthProtectedResourceMetadataResponse(ctx context.Context, logger *sl return httpcache.WriteCacheableJSON(ctx, w, r, logger, "application/json", metadataCacheMaxAgeSeconds, body) } -// ServePublic serves /mcp/{mcpSlug}. Resolution tries mcp_endpoints -// first — a slug bound to a custom-domain request resolves only against -// that domain; a slug arriving on the platform domain resolves only -// against (custom_domain_id IS NULL) endpoints. On a hit, dispatch -// matches /x/mcp: issuer-gated mcp_servers run the JWT gate before -// backend dispatch, then RemoteMcpServerID-backed rows proxy via -// remotemcp and ToolsetID-backed rows delegate to ServeToolsetResolved. +// ServePublic serves /mcp/{mcpSlug}. Resolution tries mcp_endpoints first — a +// slug bound to a custom-domain request resolves only against that domain; a +// slug arriving on the platform domain resolves only against +// (custom_domain_id IS NULL) endpoints. On a hit, issuer-gated mcp_servers run +// the JWT gate before backend dispatch; remote and tunneled rows proxy, while +// toolset-backed rows delegate to ServeToolsetResolved. // // Only a true address miss — no matching mcp_endpoint row — falls back to // the legacy toolsets.mcp_slug lookup. A matching endpoint whose backend is @@ -871,10 +866,8 @@ func hostedServingFromToolset(toolset *toolsets_repo.Toolset) *hostedServing { // serveToolsetResolved serves an MCP runtime request after the slug has // already been resolved to a toolset, under the hosting configuration cfg. // -// mcpSlug and mcpRouteBase are used to build the WWW-Authenticate -// resource_metadata URL. mcpRouteBase is the route segment that sits -// between the well-known prefix and the slug — "mcp" for /mcp/{slug} or -// "x/mcp" for /x/mcp/{slug}, no leading or trailing slashes. +// mcpSlug and mcpRouteBase build the WWW-Authenticate resource_metadata URL. +// The route base is derived from the inbound request. // // extraUpstreamTokens are the upstream remote-session access tokens // collected by a caller-side issuer gate, keyed by remote_session_issuer_id. @@ -938,11 +931,7 @@ func (s *Service) serveToolsetResolved(w http.ResponseWriter, r *http.Request, t runInToolsetGate := cfg.runInToolsetGate callerAlreadyGated := cfg.callerGated if runInToolsetGate { - // Pass mcpRouteBase (the surface the request arrived under) rather - // than letting the constructor default to "mcp": when called from - // /x/mcp the WWW-Authenticate URL, issuer URL, and consent action - // all need to match the caller's surface, not the toolset's - // canonical /mcp surface. + // Preserve the inbound route base in authentication and consent URLs. endpoint := newResolvedMcpEndpointFromToolset(toolset, mcpRouteBase) newCtx, authentication, gateToolSelection, err := s.authenticateIssuerGate(ctx, w, authToken, baseURL, endpoint) if err != nil { diff --git a/server/internal/mcp/mcpmetrics/legacyfallback.go b/server/internal/mcp/mcpmetrics/legacyfallback.go index 1c9c7d98de9..0d77b70ba7d 100644 --- a/server/internal/mcp/mcpmetrics/legacyfallback.go +++ b/server/internal/mcp/mcpmetrics/legacyfallback.go @@ -41,9 +41,7 @@ const ( LegacyFallbackWellKnownProtectedResource LegacyFallbackEntryPoint = "well_known_protected_resource" // LegacyFallbackWellKnownAuthorizationServer: RFC 8414 metadata route. LegacyFallbackWellKnownAuthorizationServer LegacyFallbackEntryPoint = "well_known_authorization_server" - // LegacyFallbackOAuth: the issuer-gated OAuth handler family resolving via - // LoadResolvedMcpEndpointBySlug (authorize, token, register, revoke, - // consent — on both the /mcp and /x/mcp surfaces). + // LegacyFallbackOAuth: the canonical issuer-gated OAuth handler family. LegacyFallbackOAuth LegacyFallbackEntryPoint = "oauth" // LegacyFallbackInstallPage: the install-page resolver in mcpmetadata. LegacyFallbackInstallPage LegacyFallbackEntryPoint = "install_page" diff --git a/server/internal/mcp/mcpmetrics/metrics.go b/server/internal/mcp/mcpmetrics/metrics.go index 5e18e14e5cc..b68955aae8e 100644 --- a/server/internal/mcp/mcpmetrics/metrics.go +++ b/server/internal/mcp/mcpmetrics/metrics.go @@ -57,9 +57,7 @@ type Metrics struct { mcpInitializeCounter metric.Int64Counter // requestCensus is the unsampled per-request census (mcp.request) emitted - // at the JSON-RPC dispatch sites for the hosted and platform surfaces. The - // remote/tunnel /x/mcp backends publish the same instrument from the - // proxy's request interceptor, which constructs its own [RequestCounter]. + // by the hosted, meta, and platform dispatchers and the remote proxy interceptor. requestCensus *RequestCounter // metaMemberDispatchCounter counts proxied meta member dials by backend @@ -289,11 +287,7 @@ func (m *Metrics) RecordMCPInitialize(ctx context.Context, requested, negotiated // RecordMCPRequest counts one dispatched MCP request on the per-request // census, dimensioned by clamped protocol revision, clamped method, and -// surface. The census semantics — what counts, what the version dimension -// means, and how this relates to `mcp.initialize` and `mcp.request.duration` — -// are documented on [RequestCounter.Record], which the remote proxy's -// interceptor invokes directly for the /x/mcp traffic that never reaches the -// mcp dispatch. +// surface. The census semantics are documented on [RequestCounter.Record]. func (m *Metrics) RecordMCPRequest(ctx context.Context, protocolVersion, method string, surface Surface) { if m == nil { return diff --git a/server/internal/mcp/mcpmetrics/surface.go b/server/internal/mcp/mcpmetrics/surface.go index b6cf823f973..1853c0fdd5d 100644 --- a/server/internal/mcp/mcpmetrics/surface.go +++ b/server/internal/mcp/mcpmetrics/surface.go @@ -2,9 +2,7 @@ package mcpmetrics // Surface identifies which inbound MCP serving surface observed a request. // The values match the policy boundaries mcpversions draws: arbitrary -// third-party clients versus the assistant-token-only platform surface. The -// /x/mcp backend fan-out (toolset-, remote-, and tunnel-backed) is -// deliberately not distinguished — all of it faces third-party clients. +// third-party clients versus the assistant-token-only platform surface. // // The go-sdk-served /platform-mcp surface (server/internal/platformmcp) is // deliberately outside this instrument: it is served by neither Gram's @@ -14,7 +12,7 @@ package mcpmetrics type Surface string const ( - // SurfaceHosting covers /mcp/{slug} and every /x/mcp/{slug} backend. + // SurfaceHosting covers hosted /mcp/{slug} endpoints. SurfaceHosting Surface = "hosting" // SurfacePlatform covers /platform/mcp/{toolsetSlug}, which accepts only diff --git a/server/internal/mcp/mcpversions/mcpversions.go b/server/internal/mcp/mcpversions/mcpversions.go index 90762fb6ef1..e9605914416 100644 --- a/server/internal/mcp/mcpversions/mcpversions.go +++ b/server/internal/mcp/mcpversions/mcpversions.go @@ -71,10 +71,10 @@ func SupportedMetaServer() []string { return slices.Clone(supportedMetaServer) } -// SupportedHostedToolset returns the revisions supported on /mcp/{slug} and on -// the toolset-backed /x/mcp/{slug}, which share a handler, oldest first. This -// surface faces arbitrary third-party MCP clients, so changing the set is a -// compatibility event with external blast radius. +// SupportedHostedToolset returns the revisions supported on hosted +// /mcp/{slug} endpoints, oldest first. This surface faces arbitrary third-party +// MCP clients, so changing the set is a compatibility event with external +// blast radius. func SupportedHostedToolset() []string { return slices.Clone(supportedHostedToolset) } diff --git a/server/internal/mcp/resolved_mcp_endpoint.go b/server/internal/mcp/resolved_mcp_endpoint.go index 71cf2e2e115..8c1eab70f3d 100644 --- a/server/internal/mcp/resolved_mcp_endpoint.go +++ b/server/internal/mcp/resolved_mcp_endpoint.go @@ -38,8 +38,9 @@ import ( // only after confirming the underlying endpoint is issuer-gated (the // user_session_issuer_id column is Valid). type ResolvedMcpEndpoint struct { - // AudienceURN is the JWT audience string used by ValidateBearer and - // Mint. /mcp uses urn.NewToolset(toolset.ID).String(); /x/mcp uses + // AudienceURN is the JWT audience string used by ValidateBearer and Mint. + // Toolset-backed legacy endpoints use urn.NewToolset(toolset.ID).String(); + // mcp_server-backed endpoints use // urn.NewUserSessionIssuer(issuerID).String(). AudienceURN string @@ -84,9 +85,9 @@ type ResolvedMcpEndpoint struct { // ProjectID owns the endpoint and scopes downstream queries. ProjectID uuid.UUID - // RouteBase is "mcp" or "x/mcp" — drives URL construction in - // WriteAuthenticateChallenge, the issuer URL emitted by /token, the - // consent form action, and the redirect from idp_callback. + // RouteBase is the inbound URL path prefix. The public runtime currently + // accepts only "mcp"; keeping the value in cached references ensures a + // challenge minted by a retired surface cannot resume on this one. RouteBase string // Slug is the public-facing endpoint slug (mcp_slug or @@ -195,7 +196,7 @@ func (e *ResolvedMcpEndpoint) ConsentURL(baseURL, stateID string) (string, error // re-resolve — not the resolved state itself — so re-entry on a subsequent // handler picks up mutations to the underlying row. baseURL is the // public base URL the challenge is being minted under (the caller's -// BaseURLForRequest); it's snapshotted into the ref so handlers that +// baseURLForRequest); it's snapshotted into the ref so handlers that // resume the challenge from a global URL (HandleIDPCallback) can // rebuild the consent redirect without re-deriving the origin. func (e *ResolvedMcpEndpoint) EndpointRef(baseURL string) EndpointRef { @@ -281,14 +282,8 @@ func (e *ResolvedMcpEndpoint) ValidateRef(ref EndpointRef) error { if e.CustomDomainID != ref.CustomDomainID { return errToolsetEndpointMismatch } - // The route surface is part of the endpoint's identity: the same slug can - // resolve on both /mcp and /x/mcp, and the RFC 9207 `iss` on every - // authorization response is built from the resolved endpoint's RouteBase. - // Resuming a challenge on the other surface would emit an issuer that - // differs from the one the client recorded at mint time, which an - // iss-validating client rejects as a mix-up. Empty ref.RouteBase is - // treated as "mcp" for states minted before EndpointRef.RouteBase existed. - if e.RouteBase != conv.Default(ref.RouteBase, "mcp") { + routeBase := conv.Default(ref.RouteBase, "mcp") + if routeBase != "mcp" || e.RouteBase != routeBase { return errToolsetEndpointMismatch } return nil @@ -301,9 +296,8 @@ func (e *ResolvedMcpEndpoint) ValidateRef(ref EndpointRef) error { // separate projects lookup since mcp_servers doesn't carry the org id // directly. AudienceURN is bound to the issuer URN rather than a // backend-specific id so tokens stay portable between toolset-backed and -// remote-backed servers under the same issuer. routeBase is the URL surface -// the request arrived under ("mcp" or "x/mcp") — always taken from the -// inbound request or the cached ref, never assumed. +// remote-backed servers under the same issuer. routeBase is derived from the +// inbound request. func NewResolvedMcpEndpointFromMcpServer( mcpEndpoint *mcpendpoints_repo.McpEndpoint, mcpServer *mcpservers_repo.McpServer, @@ -381,11 +375,8 @@ func NewResolvedMcpEndpointFromMetaMcpServer( // newResolvedMcpEndpointFromToolset materialises a ResolvedMcpEndpoint // from a resolved toolsets row. Caller is responsible for first checking -// toolset.UserSessionIssuerID.Valid. routeBase is the URL surface the -// request arrived under ("mcp" or "x/mcp") — passed explicitly because a -// toolset-backed endpoint can be addressed from either /mcp/{slug} or -// /x/mcp/{slug} and the WWW-Authenticate URL, OAuth issuer URL, and -// consent form action all need to match the caller's surface. +// toolset.UserSessionIssuerID.Valid. routeBase is derived from the inbound +// request so authentication URLs remain rooted at the addressed endpoint. func newResolvedMcpEndpointFromToolset(toolset *toolsets_repo.Toolset, routeBase string) *ResolvedMcpEndpoint { return &ResolvedMcpEndpoint{ AudienceURN: urn.NewToolset(toolset.ID).String(), @@ -405,15 +396,10 @@ func newResolvedMcpEndpointFromToolset(toolset *toolsets_repo.Toolset, routeBase } } -// loadResolvedMcpEndpointByRef resolves the cached EndpointRef stored -// on an in-flight AuthnChallengeState back to a fresh -// ResolvedMcpEndpoint and verifies its issuer FK is still live. -// Dispatches on the ref's McpServerID — when valid, resolves through the -// /x/mcp mcp_endpoints → mcp_servers path; otherwise resolves through -// the legacy /mcp toolsets path. Returns CodeNotFound when the -// underlying row is missing or no longer issuer-gated. Used by -// HandleIDPCallback (mounted under both route surfaces) to resume an -// in-flight challenge against the addressing path it was minted under. +// loadResolvedMcpEndpointByRef resolves a cached EndpointRef to a fresh +// ResolvedMcpEndpoint and verifies its issuer FK is still live. Server-backed +// refs resolve through mcp_endpoints → mcp_servers; legacy refs resolve through +// toolsets. Retired route bases fail closed. func (s *Service) loadResolvedMcpEndpointByRef(ctx context.Context, ref EndpointRef) (*ResolvedMcpEndpoint, error) { endpoint, err := s.buildResolvedMcpEndpointByRef(ctx, ref) if err != nil { @@ -426,6 +412,9 @@ func (s *Service) loadResolvedMcpEndpointByRef(ctx context.Context, ref Endpoint } func (s *Service) buildResolvedMcpEndpointByRef(ctx context.Context, ref EndpointRef) (*ResolvedMcpEndpoint, error) { + if ref.RouteBase != "" && ref.RouteBase != "mcp" { + return nil, oops.E(oops.CodeNotFound, errToolsetEndpointMismatch, "not found") + } if ref.MetaMcpServerID.Valid { return s.buildResolvedMetaMcpEndpointByRef(ctx, ref) } @@ -474,9 +463,7 @@ func (s *Service) buildResolvedMcpEndpointByRef(ctx context.Context, ref Endpoin case err != nil: return nil, oops.E(oops.CodeUnexpected, err, "load project").LogError(ctx, s.logger) } - // Refs cached before EndpointRef.RouteBase existed were only ever - // minted on the /x/mcp surface for server-keyed endpoints. - endpoint := NewResolvedMcpEndpointFromMcpServer(&mcpEndpoint, &mcpServer, project.OrganizationID, conv.Default(ref.RouteBase, "x/mcp")) + endpoint := NewResolvedMcpEndpointFromMcpServer(&mcpEndpoint, &mcpServer, project.OrganizationID, conv.Default(ref.RouteBase, "mcp")) upstreamResource, err := s.resolveUpstreamResource(ctx, s.logger, mcpEndpoint.ProjectID, &mcpServer) if err != nil { return nil, err @@ -497,8 +484,8 @@ func (s *Service) buildResolvedMcpEndpointByRef(ctx context.Context, ref Endpoin return nil, oops.E(oops.CodeNotFound, nil, "not found") } // Honour the surface the challenge was minted under so the resumed - // endpoint's URLs match the original mint. Empty ref.RouteBase falls - // back to "mcp" for states cached before EndpointRef.RouteBase existed. + // endpoint's URLs match the original mint. Empty RouteBase values predate + // the field and resolve to the canonical public surface. routeBase := ref.RouteBase if routeBase == "" { routeBase = "mcp" @@ -506,15 +493,10 @@ func (s *Service) buildResolvedMcpEndpointByRef(ctx context.Context, ref Endpoin return newResolvedMcpEndpointFromToolset(toolset, routeBase), nil } -// loadResolvedMcpEndpointByToolsetSlug resolves an mcp_slug to a -// ResolvedMcpEndpoint via the legacy toolsets path and verifies its -// issuer FK is still live. Returns CodeNotFound when either no toolset -// matches the slug or the toolset is not issuer-gated. routeBase ("mcp" -// or "x/mcp") is the surface the request arrived under and propagates -// into the resolved endpoint's URL building. Used as the fallback leaf -// of LoadResolvedMcpEndpointBySlug for slugs with no mcp_endpoint → -// mcp_server row yet (issuer-gated toolset-backed servers predating the -// toolsets → mcp_servers migration). +// loadResolvedMcpEndpointByToolsetSlug resolves an mcp_slug through the legacy +// toolsets path and verifies its issuer FK is still live. It is the fallback +// leaf of LoadResolvedMcpEndpointBySlug for slugs with no mcp_endpoint-backed +// address yet. func (s *Service) loadResolvedMcpEndpointByToolsetSlug(ctx context.Context, mcpSlug, routeBase string) (*ResolvedMcpEndpoint, error) { var customDomainID uuid.NullUUID if domainCtx := customdomains.FromContext(ctx); domainCtx != nil { @@ -579,10 +561,7 @@ func (s *Service) buildResolvedMetaMcpEndpointByRef(ctx context.Context, ref End return nil, oops.E(oops.CodeNotFound, nil, "not found") } - routeBase := ref.RouteBase - if routeBase == "" { - routeBase = "mcp" - } + routeBase := conv.Default(ref.RouteBase, "mcp") // The denormalized org id is authoritative — the composite FK on // meta_mcp_servers pins (organization_id, project_id) to the projects // row, and BuildResolvedMcpEndpointForMetaServer already relies on it. diff --git a/server/internal/mcp/resolved_mcp_endpoint_internal_test.go b/server/internal/mcp/resolved_mcp_endpoint_internal_test.go index 806b1dd10fc..b1cd68370c7 100644 --- a/server/internal/mcp/resolved_mcp_endpoint_internal_test.go +++ b/server/internal/mcp/resolved_mcp_endpoint_internal_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/google/uuid" + "github.com/speakeasy-api/gram/server/internal/oops" "github.com/stretchr/testify/require" ) @@ -12,18 +13,16 @@ func TestValidateRef_Matches(t *testing.T) { endpoint := &ResolvedMcpEndpoint{ Slug: "my-server", - RouteBase: "x/mcp", + RouteBase: "mcp", } require.NoError(t, endpoint.ValidateRef(EndpointRef{ McpSlug: "my-server", - RouteBase: "x/mcp", + RouteBase: "mcp", })) } -// A challenge minted on one route surface must not be resumable on the other: -// the RFC 9207 `iss` is built from the resolved endpoint's RouteBase, so a -// cross-surface resume would emit an issuer differing from the one the client -// recorded at mint time. +// Cached challenges from the retired runtime surface must not resume through +// the canonical route. func TestValidateRef_RejectsCrossSurfaceResume(t *testing.T) { t.Parallel() @@ -38,6 +37,19 @@ func TestValidateRef_RejectsCrossSurfaceResume(t *testing.T) { require.ErrorIs(t, err, errToolsetEndpointMismatch) } +func TestBuildResolvedMcpEndpointByRef_RejectsRetiredRouteAsNotFound(t *testing.T) { + t.Parallel() + + _, err := (&Service{}).buildResolvedMcpEndpointByRef(t.Context(), EndpointRef{ + McpSlug: "my-server", + RouteBase: "x/mcp", + }) + require.ErrorIs(t, err, errToolsetEndpointMismatch) + var oopsErr *oops.ShareableError + require.ErrorAs(t, err, &oopsErr) + require.Equal(t, oops.CodeNotFound, oopsErr.Code) +} + // States minted before EndpointRef.RouteBase existed carry an empty value, // which is treated as "mcp". func TestValidateRef_LegacyEmptyRouteBaseMeansMcp(t *testing.T) { @@ -51,13 +63,6 @@ func TestValidateRef_LegacyEmptyRouteBaseMeansMcp(t *testing.T) { McpSlug: "my-server", })) - xmcpEndpoint := &ResolvedMcpEndpoint{ - Slug: "my-server", - RouteBase: "x/mcp", - } - require.ErrorIs(t, xmcpEndpoint.ValidateRef(EndpointRef{ - McpSlug: "my-server", - }), errToolsetEndpointMismatch) } func TestValidateRef_RejectsCustomDomainMismatch(t *testing.T) { diff --git a/server/internal/mcp/serve_meta.go b/server/internal/mcp/serve_meta.go index 026db4bd1a4..9cebe5a5bef 100644 --- a/server/internal/mcp/serve_meta.go +++ b/server/internal/mcp/serve_meta.go @@ -87,7 +87,7 @@ func (s *Service) serveResolvedMetaMCPEndpoint( if err != nil { return err } - newCtx, tokens, toolSelection, err := s.ApplyIssuerGate(ctx, w, httpheaders.AuthorizationBearerToken(r), s.BaseURLForRequest(r), resolvedEndpoint) + newCtx, tokens, toolSelection, err := s.ApplyIssuerGate(ctx, w, httpheaders.AuthorizationBearerToken(r), s.baseURLForRequest(r), resolvedEndpoint) if err != nil { return fmt.Errorf("apply issuer gate: %w", err) } diff --git a/server/internal/mcp/serve_meta_test.go b/server/internal/mcp/serve_meta_test.go index 0ef787c12bc..48a74ed050e 100644 --- a/server/internal/mcp/serve_meta_test.go +++ b/server/internal/mcp/serve_meta_test.go @@ -1,7 +1,7 @@ // serve_meta_test.go verifies MCP protocol termination for meta-MCP-backed // /mcp/{slug} endpoints: 2026-07-28 initialize and server/discover, the fixed -// four-tool contract, per-request protocol-version declarations, the issuer -// gate, and the no-/x/mcp-exposure rule. +// four-tool contract, per-request protocol-version declarations, and the +// issuer gate. package mcp_test import ( @@ -504,30 +504,6 @@ func TestServePublic_MetaEndpoint_IssuerGated_NoAuth_EmitsChallenge(t *testing.T require.Equal(t, mcpversions.Version20251125, w.Header().Get(mcpversions.HTTPHeader)) } -func TestServeMCPEndpoint_MetaEndpoint_NoXmcpExposure(t *testing.T) { - t.Parallel() - - ctx, ti := newTestMCPService(t) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - - slug := "meta-" + uuid.NewString() - createMetaMcpEndpoint(t, ctx, ti.conn, *authCtx.ProjectID, authCtx.ActiveOrganizationID, slug, uuid.Nil) - - req := httptest.NewRequest(http.MethodPost, "/x/mcp/"+slug, nil) - rctx := chi.NewRouteContext() - rctx.URLParams.Add("mcpSlug", slug) - req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) - w := httptest.NewRecorder() - - err := ti.service.ServeMCPEndpoint(w, req, slug, "x/mcp") - require.Error(t, err) - var oopsErr *oops.ShareableError - require.ErrorAs(t, err, &oopsErr) - require.Equal(t, oops.CodeNotFound, oopsErr.Code) -} - func TestWellKnown_MetaEndpoint_IssuerGated_ServesMetadata(t *testing.T) { t.Parallel() diff --git a/server/internal/mcp/serveendpoint.go b/server/internal/mcp/serveendpoint.go index 649f01fdbe1..29eb277c50f 100644 --- a/server/internal/mcp/serveendpoint.go +++ b/server/internal/mcp/serveendpoint.go @@ -36,36 +36,6 @@ import ( tunneledmcprepo "github.com/speakeasy-api/gram/server/internal/tunneledmcp/repo" ) -// ServeMCPEndpoint resolves a public MCP route; mcpRouteBase preserves the called surface in auth URLs. -func (s *Service) ServeMCPEndpoint(w http.ResponseWriter, r *http.Request, slug, mcpRouteBase string) error { - ctx := r.Context() - logger := s.logger.With(attr.SlogToolsetMCPSlug(slug)) - - mcpEndpoint, mcpServer, metaServer, err := s.ResolveMCPEndpointAndServer(ctx, logger, slug) - if err != nil { - return err - } - - if metaServer != nil { - // Meta-backed endpoints are served only on the canonical /mcp - // surface; /x/mcp stays a generic-backend surface with no meta - // exposure. - if mcpRouteBase != "mcp" { - return oops.E(oops.CodeNotFound, nil, "mcp endpoint not found") - } - if err := s.enforceCustomDomainLockdown(ctx, logger, mcpEndpoint.ProjectID); err != nil { - return err - } - return s.serveResolvedMetaMCPEndpoint(w, r, logger, mcpEndpoint, metaServer) - } - - if err := s.enforceCustomDomainLockdown(ctx, logger, mcpEndpoint.ProjectID); err != nil { - return err - } - - return s.serveResolvedMCPEndpoint(w, r, logger, mcpEndpoint, mcpServer, slug, mcpRouteBase) -} - // enforceCustomDomainLockdown 403s a public-host MCP request when the owning // org's custom domain carries a non-empty IP allowlist. Such orgs require all // MCP traffic to flow through their custom domain, where the allowlist is @@ -74,8 +44,8 @@ func (s *Service) ServeMCPEndpoint(w http.ResponseWriter, r *http.Request, slug, // the allowlist for that hostname. The lockdown engages as soon as an allowlist // is configured, regardless of whether the domain is verified/activated yet. // -// This guard is wired into runtime MCP dispatch (ServePublic, -// ServeMCPEndpoint) and the consent-scoped MCP transport, which can enumerate +// This guard is wired into runtime MCP dispatch (ServePublic) and the +// consent-scoped MCP transport, which can enumerate // live inventories. The install page (ServeInstallPage / HandleGetServer's // inline browser path), consent HTML, and OAuth metadata routes are // intentionally left ungated: private-MCP install and consent pages must keep @@ -124,10 +94,9 @@ func (s *Service) customDomainLockdownApplies(ctx context.Context, logger *slog. // mcp_server) pair: it runs the issuer gate when the mcp_server is // issuer-gated and then dispatches to the appropriate backend. // -// Split from ServeMCPEndpoint so ServePublic can avoid a redundant -// resolve+lookup when it already has the rows in hand (ServePublic tries -// mcp_endpoints first and falls back to the legacy toolsets lookup on -// miss; only the hit case needs dispatch). +// ServePublic resolves the address before calling this helper, avoiding a +// redundant lookup while preserving the legacy toolset-slug fallback on a true +// mcp_endpoints miss. func (s *Service) serveResolvedMCPEndpoint( w http.ResponseWriter, r *http.Request, @@ -171,7 +140,7 @@ func (s *Service) serveResolvedMCPEndpoint( return err } upstreamResource = resolvedEndpoint.UpstreamResource - newCtx, authentication, toolSelection, err := s.authenticateIssuerGate(ctx, w, httpheaders.AuthorizationBearerToken(r), s.BaseURLForRequest(r), resolvedEndpoint) + newCtx, authentication, toolSelection, err := s.authenticateIssuerGate(ctx, w, httpheaders.AuthorizationBearerToken(r), s.baseURLForRequest(r), resolvedEndpoint) if err != nil { return fmt.Errorf("apply issuer gate: %w", err) } @@ -184,7 +153,7 @@ func (s *Service) serveResolvedMCPEndpoint( // upstream 401/403 relayed by the proxy must challenge them with // this endpoint's resource metadata — the upstream's own challenge // would misdirect their re-auth at the upstream's AS. - protectedResourceURL, err := resolvedEndpoint.ProtectedResourceURL(s.BaseURLForRequest(r)) + protectedResourceURL, err := resolvedEndpoint.ProtectedResourceURL(s.baseURLForRequest(r)) if err != nil { return oops.E(oops.CodeUnexpected, err, "build protected-resource URL").LogError(ctx, logger) } @@ -395,8 +364,8 @@ func routeFailClosed(ctx context.Context, logger *slog.Logger, reason string, to return &upstreamRoutingError{reason: reason, detail: detail} } -// ResolveMCPEndpointAndServer walks the runtime addressing chain shared by -// the /mcp and /x/mcp slug handlers and the .well-known routes: it scopes +// ResolveMCPEndpointAndServer walks the runtime addressing chain shared by the +// /mcp slug handler and well-known routes: it scopes // the lookup to the request's customdomains.Context, loads the // mcp_endpoint by (slug, custom domain), then loads the linked mcp_server. // Disabled servers and missing rows both surface as 404 to avoid leaking @@ -407,15 +376,14 @@ func routeFailClosed(ctx context.Context, logger *slog.Logger, reason string, to // back to a legacy lookup (e.g. /mcp's existing toolsets path) should // check for oops.CodeNotFound and proceed accordingly. // -// Thin wrapper around mcpendpoints.BySlugAndCustomDomain; kept as a method -// for the existing /mcp and /x/mcp call sites. +// Thin wrapper around mcpendpoints.BySlugAndCustomDomain. func (s *Service) ResolveMCPEndpointAndServer(ctx context.Context, logger *slog.Logger, slug string) (*mcpendpointsrepo.McpEndpoint, *mcpserversrepo.McpServer, *metamcprepo.MetaMcpServer, error) { return mcpendpoints.BySlugAndCustomDomain(ctx, s.db, logger, slug) //nolint:wrapcheck // thin passthrough; underlying error already carries context. } -// LoadResolvedMcpEndpointBySlug resolves a slug to a *ResolvedMcpEndpoint -// for the issuer-gated OAuth handlers, shared by both the /mcp and /x/mcp -// surfaces. It mirrors the well-known handlers' resolution model: +// LoadResolvedMcpEndpointBySlug resolves a slug to a *ResolvedMcpEndpoint for +// the issuer-gated OAuth handlers on the canonical public surface. It mirrors +// the well-known handlers' resolution model: // // - Addressing hit, issuer-gated: build the endpoint from the // (mcp_endpoint, mcp_server) pair. @@ -428,20 +396,15 @@ func (s *Service) ResolveMCPEndpointAndServer(ctx context.Context, logger *slog. // servers without an mcp_endpoint row (predating the toolsets → // mcp_servers migration) still resolve. A resolvable-but-unavailable // address (disabled wrapper, dangling backend) is terminal. -// -// mcpRouteBase ("mcp" or "x/mcp") propagates into the resolved endpoint's -// URL building on both the primary and fallback paths. -func (s *Service) LoadResolvedMcpEndpointBySlug(ctx context.Context, logger *slog.Logger, slug, mcpRouteBase string) (*ResolvedMcpEndpoint, error) { +func (s *Service) LoadResolvedMcpEndpointBySlug(ctx context.Context, logger *slog.Logger, slug string) (*ResolvedMcpEndpoint, error) { mcpEndpoint, mcpServer, metaServer, err := s.ResolveMCPEndpointAndServer(ctx, logger, slug) switch { case err == nil: if metaServer != nil { - // Meta-backed endpoints expose OAuth handlers only on the - // canonical /mcp surface, and only when issuer-gated. - if mcpRouteBase != "mcp" || !metaServer.UserSessionIssuerID.Valid { + if !metaServer.UserSessionIssuerID.Valid { return nil, oops.E(oops.CodeNotFound, nil, "not found") } - return s.BuildResolvedMcpEndpointForMetaServer(ctx, logger, mcpEndpoint, metaServer, mcpRouteBase) + return s.BuildResolvedMcpEndpointForMetaServer(ctx, logger, mcpEndpoint, metaServer, "mcp") } // Public tunneled servers serve anonymously and expose no OAuth // surface: every issuer-gated handler resolving through here @@ -450,9 +413,9 @@ func (s *Service) LoadResolvedMcpEndpointBySlug(ctx context.Context, logger *slo if !mcpServer.UserSessionIssuerID.Valid || isTunneledPublic(mcpServer) { return nil, oops.E(oops.CodeNotFound, nil, "not found") } - return s.BuildResolvedMcpEndpointForServer(ctx, logger, mcpEndpoint, mcpServer, mcpRouteBase) + return s.BuildResolvedMcpEndpointForServer(ctx, logger, mcpEndpoint, mcpServer, "mcp") case mcpendpoints.IsAddressMiss(err): - return s.loadResolvedMcpEndpointByToolsetSlug(ctx, slug, mcpRouteBase) + return s.loadResolvedMcpEndpointByToolsetSlug(ctx, slug, "mcp") default: return nil, err } @@ -464,13 +427,8 @@ func (s *Service) LoadResolvedMcpEndpointBySlug(ctx context.Context, logger *slo // organization id (not carried on mcp_servers directly). Caller is // responsible for first checking mcpServer.UserSessionIssuerID.Valid; // this helper assumes the column has been validated and 404s if the FK -// target row has since been deleted. mcpRouteBase ("mcp" or "x/mcp") is -// applied to the resolved endpoint so subsequent URL building lands on -// the request's surface. -// -// Exported so /x/mcp's wellknown handlers can build a ResolvedMcpEndpoint -// from a previously-loaded (mcp_endpoint, mcp_server) pair without -// re-querying. +// target row has since been deleted. The route base comes from the canonical +// inbound request surface. func (s *Service) BuildResolvedMcpEndpointForServer( ctx context.Context, logger *slog.Logger, @@ -668,13 +626,12 @@ func (s *Service) prepareProxyBackendContext( // in the default branch — disabled was already filtered upstream in // ResolveMCPEndpointAndServer. // - // Issuer-gated requests have already been authenticated by - // ApplyIssuerGate in ServeMCPEndpoint: the bearer is a user-session JWT - // validated against the issuer's audience, and the AuthContext on ctx - // is stamped from it. Re-running the legacy identity-auth chain here - // would only know how to validate API keys / OAuth tokens / chat - // sessions, and would reject a perfectly valid user-session JWT. Skip - // it and trust the gate. + // Issuer-gated requests have already been authenticated before backend + // dispatch: the bearer is a user-session JWT validated against the issuer's + // audience, and the AuthContext on ctx is stamped from it. Re-running the + // legacy identity-auth chain here would only know how to validate API keys, + // OAuth tokens, or chat sessions and would reject a valid user-session JWT. + // Skip it and trust the gate. issuerGated := mcpServer.UserSessionIssuerID.Valid && !isTunneledPublic(mcpServer) if issuerGated { // Public issuer-gated endpoints may carry an anonymous subject, which diff --git a/server/internal/mcp/servepublic_mcpendpoint_test.go b/server/internal/mcp/servepublic_mcpendpoint_test.go index e6104784dcf..889eb94f943 100644 --- a/server/internal/mcp/servepublic_mcpendpoint_test.go +++ b/server/internal/mcp/servepublic_mcpendpoint_test.go @@ -740,7 +740,7 @@ func TestServePublic_McpEndpoint_IssuerGatedPrivateRemote_RBACEnforced_ResolvesG token, jti, err := usersessions.NewSigner("test-jwt-secret").Mint(usersessions.MintParams{ Subject: urn.NewUserSubject(mockidp.MockUserID), Audience: urn.NewUserSessionIssuer(issuerID).String(), - Issuer: ti.serverURL.String() + "/x/mcp/" + endpointSlug, + Issuer: ti.serverURL.String() + "/mcp/" + endpointSlug, Lifetime: time.Hour, }) require.NoError(t, err) @@ -805,7 +805,7 @@ func TestServePublic_McpEndpoint_IssuerGatedPrivateRemote_RBACEnforced_RequiresC token, jti, err := usersessions.NewSigner("test-jwt-secret").Mint(usersessions.MintParams{ Subject: urn.NewUserSubject(mockidp.MockUserID), Audience: urn.NewUserSessionIssuer(issuerID).String(), - Issuer: ti.serverURL.String() + "/x/mcp/" + endpointSlug, + Issuer: ti.serverURL.String() + "/mcp/" + endpointSlug, Lifetime: time.Hour, }) require.NoError(t, err) diff --git a/server/internal/mcp/servepublic_test.go b/server/internal/mcp/servepublic_test.go index 5b9d361a218..ec195811d00 100644 --- a/server/internal/mcp/servepublic_test.go +++ b/server/internal/mcp/servepublic_test.go @@ -409,6 +409,22 @@ func TestServePublic_AttachedNotFoundReturnsMCPErrorWithNullID(t *testing.T) { require.Equal(t, "mcp server not found", errorBody["message"]) } +func TestAttachDoesNotRegisterRetiredXMCPRoute(t *testing.T) { + t.Parallel() + + _, ti := newTestMCPService(t) + router := goahttp.NewMuxer() + mcp.Attach(router, ti.service, nil) + + req := httptest.NewRequest(http.MethodPost, "/x/mcp/retired", bytes.NewReader(makeInitializeBody())) + req.Header.Set("Accept", "application/json") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + require.Equal(t, http.StatusNotFound, w.Code) +} + func TestServePublic_ServerInstructionsInInitializeResponse(t *testing.T) { t.Parallel() diff --git a/server/internal/mcp/servepublic_tunneled_test.go b/server/internal/mcp/servepublic_tunneled_test.go index d2c60c6eb58..606f04d911b 100644 --- a/server/internal/mcp/servepublic_tunneled_test.go +++ b/server/internal/mcp/servepublic_tunneled_test.go @@ -519,7 +519,7 @@ func TestServePublic_Tunneled_OAuthSurfaceIs404(t *testing.T) { err = ti.service.ServeWellKnownAuthorizationServerForServer(w, req, logger, mcpEndpoint, mcpServer, "mcp") requireNotFoundOops(t, err) - _, err = ti.service.LoadResolvedMcpEndpointBySlug(ctx, logger, fixture.endpointSlug, "mcp") + _, err = ti.service.LoadResolvedMcpEndpointBySlug(ctx, logger, fixture.endpointSlug) requireNotFoundOops(t, err) } diff --git a/server/internal/mcpendpoints/resolve.go b/server/internal/mcpendpoints/resolve.go index f2b4a376ade..eca68e65b3e 100644 --- a/server/internal/mcpendpoints/resolve.go +++ b/server/internal/mcpendpoints/resolve.go @@ -35,14 +35,13 @@ func IsAddressMiss(err error) bool { } // BySlugAndCustomDomain walks the public addressing chain shared by the /mcp -// and /x/mcp slug handlers, the install-page handlers, and the .well-known -// routes: it scopes the lookup to the request's customdomains.Context, loads -// the mcp_endpoint by (slug, custom domain), then loads whichever backend the -// endpoint addresses. Exactly one of the returned server and metaServer is -// non-nil, matching the endpoint table's backend-exclusivity check. Disabled -// backends of either kind and missing rows all surface as oops.CodeNotFound to -// avoid leaking existence to unauthenticated callers. logger should already -// carry the slug attribute. +// slug handler, install-page handlers, and well-known routes. It scopes the +// lookup to the request's customdomains.Context, loads the mcp_endpoint by +// (slug, custom domain), then loads whichever backend the endpoint addresses. +// Exactly one returned server is non-nil, matching the endpoint table's +// backend-exclusivity check. Disabled backends and missing rows surface as +// oops.CodeNotFound to avoid leaking existence to unauthenticated callers. +// logger should already carry the slug attribute. // // Callers that want to fall back to a legacy lookup (e.g. /mcp's existing // toolsets.mcp_slug path) may do so only on a true address miss — a diff --git a/server/internal/middleware/chat_session_cors.go b/server/internal/middleware/chat_session_cors.go index e4adae1bba2..fb0299b9538 100644 --- a/server/internal/middleware/chat_session_cors.go +++ b/server/internal/middleware/chat_session_cors.go @@ -33,10 +33,9 @@ var chatSessionsAllowedRoutes = []string{ // This is deliberately an allowlist rather than "every chatSessionsAllowedRoutes // entry". The /mcp prefix in that list also covers /mcp/{slug} and the OAuth // sub-routes (/token, /register, /authorize, /connect), none of which read -// Gram-Key at all — nothing under internal/mcp or internal/xmcp consults it, -// as MCP identity auth reads Authorization or Gram-Chat-Session only. Echoing -// the origin there authenticated nothing and let any page read a response it -// should not have been able to: a hostile origin that attached a dummy +// Gram-Key at all — MCP identity auth reads Authorization or Gram-Chat-Session +// only. Echoing the origin there authenticated nothing and let any page read a +// response it should not have been able to: a hostile origin that attached a // Gram-Key got Access-Control-Allow-Origin plus Allow-Credentials on a // credential-free public MCP server, making tools/list and tools/call results // readable cross-site. diff --git a/server/internal/middleware/mcp_protocol_version.go b/server/internal/middleware/mcp_protocol_version.go index 443c9f5d0f9..aca200a535d 100644 --- a/server/internal/middleware/mcp_protocol_version.go +++ b/server/internal/middleware/mcp_protocol_version.go @@ -75,11 +75,6 @@ func isMCPJSONRPCEndpoint(path string) bool { // /mcp/{mcpSlug} — the hosted toolset endpoint. A further slash means // an OAuth or metadata sub-route, not the MCP endpoint itself. return isEndpointSlug(tail) && !isSlugSiblingRoute(tail) - case "x": - // /x/mcp/{slug} — toolset-backed, remote-backed, and tunneled. Carries - // the same OAuth callback siblings as /mcp/ (internal/xmcp/service.go). - slug, ok := strings.CutPrefix(tail, "mcp/") - return ok && isEndpointSlug(slug) && !isSlugSiblingRoute(slug) case "platform": // /platform/mcp/{toolsetSlug}. Nothing static is registered beside it, // so every one-segment tail here really is a slug. @@ -106,7 +101,7 @@ func isEndpointSlug(seg string) bool { } // isSlugSiblingRoute reports whether seg names a static route registered -// directly under /mcp/ or /x/mcp/ rather than an endpoint slug. +// directly under /mcp/ rather than an endpoint slug. // // chi resolves a static pattern ahead of a parameterized one, so these paths // always reach their own handler and never the MCP endpoint handler. This @@ -123,12 +118,12 @@ func isEndpointSlug(seg string) bool { // original predicate did to production. func isSlugSiblingRoute(seg string) bool { switch seg { - // GET /mcp/idp_callback and GET /x/mcp/idp_callback: the upstream IdP - // redirects the browser here to complete an authorization code exchange. + // GET /mcp/idp_callback: the upstream IdP redirects the browser here to + // complete an authorization code exchange. case "idp_callback": return true - // GET /mcp/remote_login_callback and GET /x/mcp/remote_login_callback: the - // same, for the remote-session login flow that writes remote_sessions. + // GET /mcp/remote_login_callback: the remote-session login flow writes + // remote_sessions before returning to the consent screen. case "remote_login_callback": return true } diff --git a/server/internal/middleware/mcp_protocol_version_test.go b/server/internal/middleware/mcp_protocol_version_test.go index 0ef9fdc2c55..a1b950778e9 100644 --- a/server/internal/middleware/mcp_protocol_version_test.go +++ b/server/internal/middleware/mcp_protocol_version_test.go @@ -14,7 +14,6 @@ import ( "github.com/speakeasy-api/gram/server/internal/mcp" "github.com/speakeasy-api/gram/server/internal/mcp/mcpversions" "github.com/speakeasy-api/gram/server/internal/middleware" - "github.com/speakeasy-api/gram/server/internal/xmcp" ) // recordSpanForRequest runs the middleware inside a recorded span, mimicking @@ -58,13 +57,6 @@ func TestMCPProtocolVersionTelemetryRecordsHeaderOnHostedEndpoint(t *testing.T) require.Equal(t, mcpversions.Version20250618, got[string(attr.McpNegotiatedProtocolVersionKey)]) } -func TestMCPProtocolVersionTelemetryRecordsHeaderOnXMCPEndpoint(t *testing.T) { - t.Parallel() - - got := recordSpanForRequest(t, http.MethodPost, "/x/mcp/my-server", mcpversions.Version20260728) - require.Equal(t, mcpversions.Version20260728, got[string(attr.McpNegotiatedProtocolVersionKey)]) -} - func TestMCPProtocolVersionTelemetryRecordsHeaderOnPlatformEndpoint(t *testing.T) { t.Parallel() @@ -78,7 +70,7 @@ func TestMCPProtocolVersionTelemetryCoversGetAndDelete(t *testing.T) { // Streamable HTTP uses GET to open an SSE stream and DELETE to terminate a // session; both carry the header and both reach the remote MCP proxy. for _, method := range []string{http.MethodGet, http.MethodDelete} { - got := recordSpanForRequest(t, method, "/x/mcp/my-server", mcpversions.Version20250618) + got := recordSpanForRequest(t, method, "/mcp/my-server", mcpversions.Version20250618) require.Equal(t, mcpversions.Version20250618, got[string(attr.McpNegotiatedProtocolVersionKey)], "method %s", method) } } @@ -109,7 +101,7 @@ func routePathForSlug(t *testing.T, pattern, slug string) string { func TestMCPProtocolVersionTelemetryMatchesRegisteredRoutes(t *testing.T) { t.Parallel() - patterns := []string{mcp.PublicServerRoute, mcp.PlatformToolsetRoute, xmcp.RuntimePath} + patterns := []string{mcp.PublicServerRoute, mcp.PlatformToolsetRoute} methods := []string{http.MethodPost, http.MethodGet, http.MethodDelete} for _, pattern := range patterns { @@ -154,15 +146,14 @@ func TestMCPProtocolVersionTelemetryIgnoresOAuthSubRoutes(t *testing.T) { // TestMCPProtocolVersionTelemetryIgnoresSlugSiblingRoutes is the inverse of // TestMCPProtocolVersionTelemetryMatchesRegisteredRoutes: these static routes -// are registered directly beside /mcp/{mcpSlug} and /x/mcp/{mcpSlug}, so they -// occupy the slug position without being MCP endpoints and chi resolves them -// first. Shape alone cannot tell them apart from a slug, so they are excluded -// by name. +// are registered directly beside /mcp/{mcpSlug}, so they occupy the slug +// position without being MCP endpoints and chi resolves them first. Shape +// alone cannot tell them apart from a slug, so they are excluded by name. // // Keep this list in lockstep with the one-segment routes registered in -// internal/mcp/impl.go and internal/xmcp/service.go. MCPSecurity shares this -// predicate, so a route that regresses back into it is answered with 403 -// rather than merely losing an attribute. +// internal/mcp/impl.go. MCPSecurity shares this predicate, so a route that +// regresses back into it is answered with 403 rather than merely losing an +// attribute. func TestMCPProtocolVersionTelemetryIgnoresSlugSiblingRoutes(t *testing.T) { t.Parallel() @@ -172,8 +163,6 @@ func TestMCPProtocolVersionTelemetryIgnoresSlugSiblingRoutes(t *testing.T) { "/mcp/install-page-9f86d081.js", "/mcp/consent-page-9f86d081.js", "/mcp/consent-tools-9f86d081.js", - "/x/mcp/idp_callback", - "/x/mcp/remote_login_callback", } { for _, method := range []string{http.MethodGet, http.MethodPost} { got := recordSpanForRequest(t, method, path, mcpversions.Version20250618) diff --git a/server/internal/middleware/mcp_security_test.go b/server/internal/middleware/mcp_security_test.go index ac68ccd15be..a45bac2cdbc 100644 --- a/server/internal/middleware/mcp_security_test.go +++ b/server/internal/middleware/mcp_security_test.go @@ -196,7 +196,6 @@ func TestMCPSecurity_CoversEveryMCPJSONRPCRoute(t *testing.T) { for _, path := range []string{ "/mcp/petstore", // toolset-backed and meta-MCP-backed - "/x/mcp/petstore", // experimental runtime "/platform/mcp/gram-billing", // platform toolsets "/platform-mcp", // Gram's own platform MCP server } { @@ -216,8 +215,8 @@ func TestMCPSecurity_CoversEveryMCPJSONRPCRoute(t *testing.T) { } // The OAuth callbacks and hashed browser assets registered directly under -// /mcp/ and /x/mcp/ sit in the same one-segment shape as a slug, so the -// original predicate read them as MCP endpoints and origin-checked them. +// /mcp/ sit in the same one-segment shape as a slug, so the original predicate +// read them as MCP endpoints and origin-checked them. // // A callback is reached by the browser following the upstream IdP's redirect, // which is cross-site by construction and, being a top-level navigation, @@ -231,8 +230,6 @@ func TestMCPSecurity_AllowsOAuthCallbackNavigation(t *testing.T) { for _, path := range []string{ "/mcp/idp_callback", "/mcp/remote_login_callback", - "/x/mcp/idp_callback", - "/x/mcp/remote_login_callback", } { t.Run(path, func(t *testing.T) { t.Parallel() diff --git a/server/internal/middleware/otel_public_endpoint_test.go b/server/internal/middleware/otel_public_endpoint_test.go index 9849e6c5589..38cbfa49907 100644 --- a/server/internal/middleware/otel_public_endpoint_test.go +++ b/server/internal/middleware/otel_public_endpoint_test.go @@ -58,9 +58,7 @@ func TestIsOTelPublicEndpointPublicRoutesArePublic(t *testing.T) { "/mcp/idp_callback", "/oauth/some-slug/token", "/oauth-external/callback", - "/x/mcp/some-slug", "/.well-known/oauth-protected-resource/mcp/some-slug", - "/.well-known/oauth-authorization-server/x/mcp/some-slug", "/chat/completions", "/openapi.yaml", } @@ -129,7 +127,6 @@ func TestIsOTelPublicEndpointKnownRouteSurface(t *testing.T) { "/admin/organizations.list": false, // Public untrusted surfaces. "/mcp/some-slug": true, - "/x/mcp/some-slug": true, "/oauth/some-slug/authorize": true, "/oauth-external/authorize": true, "/.well-known/oauth-protected-resource/mcp/some-slug": true, diff --git a/server/internal/oauth/wellknown/wellknown.go b/server/internal/oauth/wellknown/wellknown.go index 8770ba512fb..d2fd22880c5 100644 --- a/server/internal/oauth/wellknown/wellknown.go +++ b/server/internal/oauth/wellknown/wellknown.go @@ -91,13 +91,9 @@ type OAuthRepo interface { // ResolveOAuthServerMetadataFromToolset returns OAuth Authorization Server // metadata for a toolset, or nil if the toolset is not OAuth-configured. // -// oauthSlug is the slug used to address the Gram-hosted OAuth endpoints -// (`/oauth/{oauthSlug}/...`). Today the OAuth machinery is keyed by -// `toolsets.mcp_slug`, so callers should pass that value. The /x/mcp -// experimental endpoint uses the same OAuth flow under the hood, so it -// also passes `toolset.mcp_slug` here even though its protected-resource -// URL uses an `mcp_endpoints.slug` instead — see the companion -// resourceURL argument on [ResolveOAuthProtectedResourceFromToolset]. +// oauthSlug addresses the Gram-hosted OAuth endpoints +// (`/oauth/{oauthSlug}/...`). Callers pass the public MCP endpoint slug; the +// legacy toolset fallback passes its mcp_slug because that is its URL slug. // // resourceURL is the absolute URL of the protected resource — the same value // [ResolveOAuthProtectedResourceFromToolset] emits as `resource` and @@ -185,12 +181,10 @@ func rewriteMetadataIssuer(raw json.RawMessage, issuer string) (json.RawMessage, // ResolveOAuthProtectedResourceFromToolset returns OAuth Protected Resource // Metadata for a toolset, or nil if the toolset is not OAuth-protected. // -// resourceURL is the absolute URL of the protected resource (the runtime MCP -// endpoint). For /mcp callers this is `/mcp/`; for -// /x/mcp callers this is `/x/mcp/`. It is used -// verbatim for both `resource` and `authorization_servers` so that the -// `/.well-known/...` discovery path on the protected resource resolves back -// to the Gram-hosted authorization server metadata. +// resourceURL is the absolute URL of the protected runtime MCP endpoint. It is +// used verbatim for both resource and authorization_servers so the protected +// resource's well-known discovery path resolves back to the Gram-hosted +// authorization server metadata. func ResolveOAuthProtectedResourceFromToolset( ctx context.Context, logger *slog.Logger, diff --git a/server/internal/remotemcp/initialize_posthog_event_interceptor.go b/server/internal/remotemcp/initialize_posthog_event_interceptor.go index 1950cd60f30..12818aaadc8 100644 --- a/server/internal/remotemcp/initialize_posthog_event_interceptor.go +++ b/server/internal/remotemcp/initialize_posthog_event_interceptor.go @@ -14,15 +14,12 @@ import ( ) // eventMCPInitialized is the PostHog event name emitted for every observed -// `initialize` request. AGE-1902 tracks unifying this with the equivalent -// `/mcp` event so both runtimes share a single product-analytics schema. +// initialize request. const eventMCPInitialized = "mcp_initialized" -// InitializePostHogEventInterceptor emits the [eventMCPInitialized] PostHog -// event for every JSON-RPC `initialize` request observed by `/x/mcp`. It is -// a [proxy.InitializeRequestInterceptor]: the proxy routes only initialize -// requests to it, so the interceptor never has to dispatch on method. -// Analytics emission is best-effort and never rejects. +// InitializePostHogEventInterceptor emits [eventMCPInitialized] for every +// JSON-RPC initialize request observed by the remote MCP proxy. The proxy routes +// only initialize requests to it, and analytics emission is best-effort. type InitializePostHogEventInterceptor struct { posthog *posthog.Posthog identity proxy.ServerIdentity diff --git a/server/internal/remotemcp/proxy/proxy.go b/server/internal/remotemcp/proxy/proxy.go index 763b54c4348..28c3ca90b54 100644 --- a/server/internal/remotemcp/proxy/proxy.go +++ b/server/internal/remotemcp/proxy/proxy.go @@ -620,10 +620,8 @@ func (p *Proxy) Post(w http.ResponseWriter, r *http.Request) (err error) { toolsCallReq = nil } if toolsCallReq != nil { - // Attach the tool name to the parent Post span so the existing - // `tool_name` materialized column on ClickHouse `telemetry_logs` - // populates for /x/mcp traffic without any further plumbing — - // matching how /mcp aggregations already work. + // Attach the tool name to the parent Post span so the ClickHouse + // telemetry_logs materialized column is populated. span.SetAttributes(attr.ToolName(toolsCallReq.Params.Name)) if err := p.runToolsCallRequestInterceptors(ctx, toolsCallReq); err != nil { return p.dispatchInterceptorError(ctx, w, span, userReqID, err, &responseBytes) @@ -650,9 +648,7 @@ func (p *Proxy) Post(w http.ResponseWriter, r *http.Request) (err error) { resourcesReadReq, _ := resourcesReadRequestFromUserRequest(userReq) if resourcesReadReq != nil { - // Attach the resource URI to the parent Post span so the - // `gram.resource.uri` attribute on traces populates for /x/mcp - // resource reads, mirroring how tools/call sets `tool_name`. + // Attach the resource URI to the parent Post span for trace attribution. span.SetAttributes(attr.ResourceURI(resourcesReadReq.Params.URI)) if err := p.runResourcesReadRequestInterceptors(ctx, resourcesReadReq); err != nil { return p.dispatchInterceptorError(ctx, w, span, userReqID, err, &responseBytes) diff --git a/server/internal/remotemcp/proxy/reject_error.go b/server/internal/remotemcp/proxy/reject_error.go index a51000958fd..df599106f56 100644 --- a/server/internal/remotemcp/proxy/reject_error.go +++ b/server/internal/remotemcp/proxy/reject_error.go @@ -127,10 +127,8 @@ func RejectErrorFromCause(err error) *RejectError { } } -// rejectCodeForOops mirrors the mapping used by the /mcp endpoint's -// NewErrorFromCause so an oops.ShareableError leaving an interceptor -// produces the same JSON-RPC code regardless of which surface (the public -// /mcp server or the /x/mcp proxy) it traversed. +// rejectCodeForOops mirrors the hosted endpoint's mapping so an +// oops.ShareableError leaving an interceptor produces the same JSON-RPC code. func rejectCodeForOops(code oops.Code) int { switch code { case oops.CodeBadRequest: diff --git a/server/internal/remotemcp/proxymanager.go b/server/internal/remotemcp/proxymanager.go index 332a0ec9ac5..2a8ea3e2d0c 100644 --- a/server/internal/remotemcp/proxymanager.go +++ b/server/internal/remotemcp/proxymanager.go @@ -73,10 +73,8 @@ type ProxyManager struct { identityCoverage *mcptoolexecution.IdentityCoverageCheckpoint killswitchCheckpoint *mcptoolexecution.Checkpoint - // requestOTELCounterInterceptor emits the shared per-request census - // counter (mcp.request) for the remote- and tunnel-backed /x/mcp traffic, - // which never reaches the mcp package's dispatch where the hosted and - // platform surfaces emit it. + // requestOTELCounterInterceptor emits the shared per-request census for + // remote and tunneled proxy traffic. requestOTELCounterInterceptor *RequestOTELCounterInterceptor toolDispositions ToolDispositionResolver diff --git a/server/internal/remotemcp/request_otel_counter_interceptor.go b/server/internal/remotemcp/request_otel_counter_interceptor.go index a6c43103664..57474ae3339 100644 --- a/server/internal/remotemcp/request_otel_counter_interceptor.go +++ b/server/internal/remotemcp/request_otel_counter_interceptor.go @@ -11,10 +11,9 @@ import ( "github.com/speakeasy-api/gram/server/internal/remotemcp/proxy" ) -// RequestOTELCounterInterceptor records the per-request MCP census (`mcp.request`) -// for the remote- and tunnel-backed `/x/mcp` traffic, which bypasses the mcp -// package's JSON-RPC dispatch where the hosted and platform surfaces emit the -// same instrument. It is a [proxy.UserRequestInterceptor] so it observes every +// RequestOTELCounterInterceptor records the per-request MCP census +// (`mcp.request`) for proxy-backed traffic. It is a +// [proxy.UserRequestInterceptor] so it observes every // parsed inbound message regardless of method, after routing and // authentication — matching the dispatch-site semantics on the other surfaces. // diff --git a/server/internal/remotemcp/resources_read_usage_tracking_interceptor.go b/server/internal/remotemcp/resources_read_usage_tracking_interceptor.go index 1c8417aeb99..6c8281e87ac 100644 --- a/server/internal/remotemcp/resources_read_usage_tracking_interceptor.go +++ b/server/internal/remotemcp/resources_read_usage_tracking_interceptor.go @@ -63,7 +63,7 @@ func (i *ResourcesReadUsageTrackingInterceptor) InterceptResourcesReadResponse(c authCtx, ok := contextvalues.GetAuthContext(ctx) if !ok || authCtx == nil || authCtx.ProjectID == nil { i.logger.WarnContext(ctx, "skipping resource read usage tracking: missing auth context", - attr.SlogComponent("xmcp")) + attr.SlogComponent("mcp")) return nil } diff --git a/server/internal/remotemcp/tools_call_clickhouse_log_interceptor.go b/server/internal/remotemcp/tools_call_clickhouse_log_interceptor.go index 48e695428cf..b4497d93dda 100644 --- a/server/internal/remotemcp/tools_call_clickhouse_log_interceptor.go +++ b/server/internal/remotemcp/tools_call_clickhouse_log_interceptor.go @@ -32,13 +32,12 @@ var DurationMissingKey = attribute.Key("gram.telemetry.duration_missing") // from http.server.request.duration). // // One instance implements both [proxy.ToolsCallRequestInterceptor] and -// [proxy.ToolsCallResponseInterceptor]. xmcp constructs a fresh interceptor -// per HTTP request inside [Service.buildProxy], and [proxy.Proxy.Post] fires -// the request and response chains sequentially on a single goroutine for at -// most one tools/call per request — so a single nilable start timestamp is -// enough state to compute duration, no map or mutex required. If a future -// refactor makes [proxy.Proxy] (or this interceptor) long-lived across -// requests, this needs to revert to per-call keying. +// [proxy.ToolsCallResponseInterceptor]. A fresh interceptor is constructed for +// each HTTP request, and [proxy.Proxy.Post] runs the request and response chains +// sequentially on one goroutine for at most one tools/call per request, so a +// single nilable start timestamp is safe. +// A future refactor making the proxy or interceptor long-lived across requests +// would need per-call keying. // // Emission is fire-and-forget on a goroutine bound to // [context.WithoutCancel] so ClickHouse latency never appears in the user's @@ -96,7 +95,7 @@ func (i *ToolsCallClickHouseLogInterceptor) InterceptToolsCallResponse(ctx conte authCtx, ok := contextvalues.GetAuthContext(ctx) if !ok || authCtx == nil || authCtx.ProjectID == nil { i.logger.WarnContext(ctx, "skipping tools/call clickhouse log: missing auth context", - attr.SlogComponent("xmcp")) + attr.SlogComponent("mcp")) return nil } diff --git a/server/internal/remotemcp/tools_call_usage_tracking_interceptor.go b/server/internal/remotemcp/tools_call_usage_tracking_interceptor.go index 535220ce1cf..3bf4bf31a89 100644 --- a/server/internal/remotemcp/tools_call_usage_tracking_interceptor.go +++ b/server/internal/remotemcp/tools_call_usage_tracking_interceptor.go @@ -65,7 +65,7 @@ func (i *ToolsCallUsageTrackingInterceptor) InterceptToolsCallResponse(ctx conte authCtx, ok := contextvalues.GetAuthContext(ctx) if !ok || authCtx == nil || authCtx.ProjectID == nil { i.logger.WarnContext(ctx, "skipping tool call usage tracking: missing auth context", - attr.SlogComponent("xmcp")) + attr.SlogComponent("mcp")) return nil } diff --git a/server/internal/remotemcp/tools_list_posthog_event_interceptor.go b/server/internal/remotemcp/tools_list_posthog_event_interceptor.go index 91f4455f895..87df5325b78 100644 --- a/server/internal/remotemcp/tools_list_posthog_event_interceptor.go +++ b/server/internal/remotemcp/tools_list_posthog_event_interceptor.go @@ -23,17 +23,9 @@ import ( const eventMCPServerToolsList = "mcp_server_tools_list" // ToolsListPostHogEventInterceptor emits the [eventMCPServerToolsList] PostHog -// event for every JSON-RPC `tools/list` request observed by `/x/mcp`. It is a -// [proxy.ToolsListRequestInterceptor]: emitting on the request side mirrors -// `/mcp`'s placement of the equivalent event before any per-tool filtering or -// upstream call, so the event records "tools/list was attempted on this -// server" regardless of upstream success. -// -// The event is renamed from `/mcp`'s `mcp_server_count` because the property -// schema differs — the toolset-shaped fields (`toolset_id`, `toolset_slug`, -// etc.) are replaced with `remote_mcp_server_id` since `/x/mcp` proxies a -// Remote MCP Server rather than wrapping a Gram-managed toolset. AGE-1902 -// tracks unifying the two runtimes onto this single event name. +// event for every JSON-RPC tools/list request observed by the remote MCP proxy. +// It emits before per-tool filtering or the upstream call, recording that the +// request was attempted. type ToolsListPostHogEventInterceptor struct { posthog *posthog.Posthog identity proxy.ServerIdentity diff --git a/server/internal/remotesessions/challenge.go b/server/internal/remotesessions/challenge.go index 1e08af26ece..a706baa8dc6 100644 --- a/server/internal/remotesessions/challenge.go +++ b/server/internal/remotesessions/challenge.go @@ -66,12 +66,8 @@ import ( // upstream token exchange instead of bouncing back to // //{slug}/connect. // -// RouteBase is "mcp" or "x/mcp" — the surface the parent challenge was -// minted under. Drives both the upstream provider's redirect_uri -// (//remote_login_callback) and the post-callback bounce to -// //{slug}/connect. Empty values fall back to "mcp" so -// in-flight states minted before this field landed still resume on the -// original surface. +// RouteBase is the inbound MCP route stored on the parent challenge. Only +// "mcp" is accepted; empty values from older cached states resolve to it. type ParentChallenge struct { ID string ProjectID uuid.UUID @@ -109,9 +105,9 @@ type RemoteLoginState struct { Resource string `json:"resource,omitempty"` Subject *urn.SessionSubject `json:"subject,omitempty"` McpSlug string `json:"mcp_slug"` - // RouteBase is "mcp" or "x/mcp" — drives the post-callback redirect - // to //{slug}/connect. Empty values fall back to "mcp" - // for in-flight states minted before this field landed. + // RouteBase is the inbound MCP route used for the post-callback redirect. + // Only "mcp" is accepted; empty values from older cached states resolve to + // it. RouteBase string `json:"route_base,omitempty"` // FinalRedirectURI overrides the default post-callback redirect to // //{slug}/connect. Set by dashboard-driven flows that @@ -489,6 +485,13 @@ func (m *ChallengeManager) BuildAuthorizationUrl( // Counted at entry, before any validation or the Redis write, so a flow // that dies on an unrelated error here still lands in the census. m.metrics.Record(ctx, client.IssuerURL, remotesessionmetrics.ClassifyPKCESupport(client.IssuerCodeChallengeMethodsSupported)) + routeBase := parent.RouteBase + if routeBase == "" { + routeBase = canonicalCallbackRouteBase + } + if routeBase != canonicalCallbackRouteBase { + return "", fmt.Errorf("unsupported MCP route base %q", routeBase) + } if client.AuthorizationEndpoint == "" { return "", fmt.Errorf("remote_session_issuer %s missing authorization_endpoint", client.IssuerSlug) @@ -541,7 +544,7 @@ func (m *ChallengeManager) BuildAuthorizationUrl( Resource: parent.Resource, Subject: parent.Subject, McpSlug: parent.McpSlug, - RouteBase: parent.RouteBase, + RouteBase: routeBase, FinalRedirectURI: parent.FinalRedirectURI, AutoRefresh: parent.AutoRefresh, CreatedAt: time.Now(), @@ -801,7 +804,10 @@ func (m *ChallengeManager) HandleRemoteLoginCallback(w http.ResponseWriter, r *h routeBase := state.RouteBase if routeBase == "" { - routeBase = "mcp" + routeBase = canonicalCallbackRouteBase + } + if routeBase != canonicalCallbackRouteBase { + return oops.E(oops.CodeBadRequest, nil, "unsupported MCP route").LogWarn(ctx, logger) } redirect := fmt.Sprintf("%s/%s/%s/connect?state=%s", strings.TrimRight(m.serverURL.String(), "/"), routeBase, mcpSlug, url.QueryEscape(state.ParentChallengeID)) if state.FinalRedirectURI != "" { @@ -811,19 +817,13 @@ func (m *ChallengeManager) HandleRemoteLoginCallback(w http.ResponseWriter, r *h return nil } -// canonicalCallbackRouteBase is the route base the outbound remote-login -// redirect_uri uses. remote_login_callback is mounted slug-less under both /mcp -// and /x/mcp and recovers the originating slug from the cached login state, so -// one canonical base serves either surface. A single stable redirect_uri also -// matches the lone redirect_uri a CIMD client publishes in its metadata -// document; the originating surface lives in the login state's RouteBase for -// the post-callback bounce. +// canonicalCallbackRouteBase is the only route base the outbound remote-login +// redirect_uri and post-callback consent redirect use. const canonicalCallbackRouteBase = "mcp" -// callbackURL is the route-base-scoped path the upstream provider redirects -// back to after the user authenticates. Empty routeBase falls back to "mcp" -// for back-compat with callers that haven't been threaded with a RouteBase -// yet (and for in-flight states minted before this parameter landed). +// callbackURL is the path the upstream provider redirects back to after the +// user authenticates. Empty routeBase values resolve to the canonical route for +// callers and cached states predating this field. func (m *ChallengeManager) callbackURL(routeBase string) string { if routeBase == "" { routeBase = canonicalCallbackRouteBase diff --git a/server/internal/remotesessions/proxyregister.go b/server/internal/remotesessions/proxyregister.go index 5ead6c86434..a136b36b0fa 100644 --- a/server/internal/remotesessions/proxyregister.go +++ b/server/internal/remotesessions/proxyregister.go @@ -97,7 +97,6 @@ func RegisterDynamicClient(ctx context.Context, policy *guardian.Policy, serverU redirectURIs := []string{ fmt.Sprintf("%s/oauth/callback", origin), fmt.Sprintf("%s/mcp/remote_login_callback", origin), - fmt.Sprintf("%s/x/mcp/remote_login_callback", origin), } dcrReq := DCRRequest{ diff --git a/server/internal/shadowmcp/schema.go b/server/internal/shadowmcp/schema.go index 5916e3d735d..cfc979f7950 100644 --- a/server/internal/shadowmcp/schema.go +++ b/server/internal/shadowmcp/schema.go @@ -11,11 +11,9 @@ import ( // scopeID. Tool callers must echo the value back so downstream validators // can recover which Gram toolset authored the call. // -// Only the toolset-backed `/mcp` path injects today. The remote MCP proxy -// (`/x/mcp`, plus `/mcp` remote-backed and tunneled servers) stopped -// injecting and stopped validating the echo in DNO-603, because models -// routinely failed to echo the value and the proxy already knew which -// server it had routed to. +// The toolset-backed path injects this signature. Remote and tunneled proxies +// stopped injecting and validating the echo in DNO-603 because models routinely +// failed to echo the value and the proxy already knew which server it routed to. // // The schema is mutated as a structural map operation: the function // unmarshals the schema into [map[string]any], adds the property to diff --git a/server/internal/usersessions/minthandler.go b/server/internal/usersessions/minthandler.go index e4856dcb8c0..027d5829d7c 100644 --- a/server/internal/usersessions/minthandler.go +++ b/server/internal/usersessions/minthandler.go @@ -41,9 +41,8 @@ const ( dashboardMintRefreshTokenHashPrefix = "dashboard-mint" ) -// mintTarget is the issuer-gated audience the JWT is bound to, resolved from -// either a toolset (/mcp) or a remote MCP server (/x/mcp) before the shared -// mint+persist tail runs. +// mintTarget is the issuer-gated audience the JWT is bound to, resolved from a +// toolset or MCP server before the shared mint-and-persist tail runs. type mintTarget struct { issuerID uuid.UUID audience string @@ -54,12 +53,10 @@ type mintTarget struct { logAttr slog.Attr } -// MintUserSession issues a user-session JWT against an issuer-gated audience — -// either a toolset (/mcp) or a remote MCP server (/x/mcp) — on behalf of the -// authenticated dashboard user. Exactly one of toolset_id / mcp_server_id must -// be set. The resulting JWT has the same shape as the one /token would emit -// after a real OAuth dance, so the runtime gateway validates it through the -// existing validateUserSessionToken path with no special-casing. +// MintUserSession issues a user-session JWT against an issuer-gated toolset or +// MCP server on behalf of the authenticated dashboard user. Exactly one target +// must be set. The resulting JWT matches the token endpoint's shape, so the +// runtime validates it through the same path as a real OAuth dance. // // Auth posture: dashboard session only (see design.go, which scopes the method // to security.Session). API-key callers are rejected at the security scheme diff --git a/server/internal/xmcp/handler.go b/server/internal/xmcp/handler.go deleted file mode 100644 index 147182bb426..00000000000 --- a/server/internal/xmcp/handler.go +++ /dev/null @@ -1,27 +0,0 @@ -package xmcp - -import ( - "fmt" - "net/http" - - "github.com/go-chi/chi/v5" - - "github.com/speakeasy-api/gram/server/internal/oops" -) - -// ServeMCP handles DELETE, GET, and POST on /x/mcp/{slug} by forwarding -// to the unified mcp.Service.ServeMCPEndpoint dispatcher with the -// "x/mcp" route base. The dispatcher resolves the slug, runs the issuer -// gate when applicable, and dispatches to the remote-MCP proxy or the -// toolset-backed handler. -func (s *Service) ServeMCP(w http.ResponseWriter, r *http.Request) error { - slug := chi.URLParam(r, "slug") - if slug == "" { - return oops.E(oops.CodeBadRequest, nil, "an mcp slug must be provided") - } - - if err := s.mcpService.ServeMCPEndpoint(w, r, slug, "x/mcp"); err != nil { - return fmt.Errorf("serve mcp endpoint: %w", err) - } - return nil -} diff --git a/server/internal/xmcp/issuer_gated_mcp_server_test.go b/server/internal/xmcp/issuer_gated_mcp_server_test.go deleted file mode 100644 index 81eb2c58dee..00000000000 --- a/server/internal/xmcp/issuer_gated_mcp_server_test.go +++ /dev/null @@ -1,208 +0,0 @@ -// issuer_gated_mcp_server_test.go provides the [createIssuerGatedMcpServer] -// helper that wires up a full /x/mcp/{slug} resolution chain plus the -// upstream-IDP plumbing (user_session_issuer + remote_session_issuer + -// DCR-registered remote_session_client). The shape mirrors -// [oauthtest.CreateIssuerGatedToolset] but operates on the -// mcp_servers / mcp_endpoints model used by /x/mcp rather than the -// legacy toolsets-keyed model used by /mcp. -// -// Kept as test-internal because /x/mcp integration tests are today the -// only consumer; promote to a public xmcptest package if a second consumer -// shows up. -package xmcp_test - -import ( - "context" - "testing" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/stretchr/testify/require" - - "github.com/speakeasy-api/gram/server/internal/contextvalues" - "github.com/speakeasy-api/gram/server/internal/encryption" - mcpendpoints_repo "github.com/speakeasy-api/gram/server/internal/mcpendpoints/repo" - "github.com/speakeasy-api/gram/server/internal/mcpservers" - mcpservers_repo "github.com/speakeasy-api/gram/server/internal/mcpservers/repo" - "github.com/speakeasy-api/gram/server/internal/oauthtest" - "github.com/speakeasy-api/gram/server/internal/remotemcp/remotemcptest" - remotemcp_repo "github.com/speakeasy-api/gram/server/internal/remotemcp/repo" - remotesessions_repo "github.com/speakeasy-api/gram/server/internal/remotesessions/repo" - toolsets_repo "github.com/speakeasy-api/gram/server/internal/toolsets/repo" - usersessions_repo "github.com/speakeasy-api/gram/server/internal/usersessions/repo" -) - -// issuerGatedBackend selects which backend the seeded mcp_server should -// point at. -type issuerGatedBackend int - -const ( - // issuerGatedBackendToolset wires the mcp_server.toolset_id to a - // fresh toolsets row. - issuerGatedBackendToolset issuerGatedBackend = iota - // issuerGatedBackendRemote wires the mcp_server.remote_mcp_server_id - // to a fresh remote_mcp_servers row. - issuerGatedBackendRemote -) - -// issuerGatedMcpServerOpts configures [createIssuerGatedMcpServer]. -type issuerGatedMcpServerOpts struct { - // Backend selects toolset-backed vs remote-backed. - Backend issuerGatedBackend - // Slug prefix for the mcp_endpoints.slug. A UUID suffix is appended. - // For toolset-backed servers the toolsets.mcp_slug uses the same value - // so resolution lines up with the production assumption. - Slug string - // Visibility is "public", "private", or "disabled". Required. - Visibility string - // UpstreamMetadata is RFC 8414 JSON describing the remote authorization - // server (e.g. devidptest.Instance.OAuth21Metadata(t)). The helper reads - // issuer / authorization_endpoint / token_endpoint / registration_endpoint - // out of this document and DCR-registers a remote_session_client. - UpstreamMetadata []byte - // RemoteSessionCallbackBaseURL, when set, registers the static Gram - // /mcp/remote_login_callback URL. Tests that drive a real upstream - // authorize flow should set this to the Gram server URL. - RemoteSessionCallbackBaseURL string - // AuthnChallengeMode is "chain" or "interactive". Default "interactive". - AuthnChallengeMode string - // RemoteUpstreamURL is the upstream URL stored on the remote_mcp_servers - // row for issuerGatedBackendRemote. Required for that backend. - RemoteUpstreamURL string - // CustomDomainID, when Valid, scopes the resulting mcp_endpoint to a - // custom_domains row so resolution only succeeds for requests carrying - // a matching customdomains.Context. - CustomDomainID uuid.NullUUID -} - -// issuerGatedMcpServerResult holds the rows created by [createIssuerGatedMcpServer]. -type issuerGatedMcpServerResult struct { - // Slug is the mcp_endpoints.slug (also the toolsets.mcp_slug for the - // toolset backend). - Slug string - // McpEndpoint is the mcp_endpoints row exposing the server via Slug. - McpEndpoint mcpendpoints_repo.McpEndpoint - // McpServer is the issuer-gated mcp_servers row. - McpServer mcpservers_repo.McpServer - // UserSessionIssuer gates the mcp_server. - UserSessionIssuer usersessions_repo.UserSessionIssuer - // RemoteSessionIssuer is the upstream-IDP discovery row. - RemoteSessionIssuer remotesessions_repo.RemoteSessionIssuer - // RemoteSessionClient is the DCR-registered upstream client bound to - // the UserSessionIssuer. - RemoteSessionClient remotesessions_repo.RemoteSessionClient - // Toolset is populated only for the toolset backend. - Toolset *toolsets_repo.Toolset - // RemoteMcpServer is populated only for the remote backend. - RemoteMcpServer *remotemcp_repo.RemoteMcpServer -} - -// createIssuerGatedMcpServer wires up a full /x/mcp/{slug} resolution chain -// for an issuer-gated mcp_server: a user_session_issuer + one -// remote_session_issuer + one DCR-registered remote_session_client + a -// backend row (toolset or remote_mcp_server) + the mcp_server pointing at -// both the issuer and the backend + the mcp_endpoint exposing it via the -// returned slug. Intentionally analogous to -// [oauthtest.CreateIssuerGatedToolset] so /x/mcp integration tests drive -// the same upstream-IDP-backed OAuth dance against /x/mcp/{slug} that the -// /mcp tests already drive against the toolset-keyed surface. -func createIssuerGatedMcpServer( - t *testing.T, - ctx context.Context, - conn *pgxpool.Pool, - enc *encryption.Client, - authCtx *contextvalues.AuthContext, - opts issuerGatedMcpServerOpts, -) issuerGatedMcpServerResult { - t.Helper() - - require.NotEmpty(t, opts.Visibility, "Visibility is required") - require.NotNil(t, opts.UpstreamMetadata, "UpstreamMetadata is required") - if opts.Backend == issuerGatedBackendRemote { - require.NotEmpty(t, opts.RemoteUpstreamURL, "RemoteUpstreamURL is required for the remote backend") - } - - // Reuse oauthtest's issuer-gated bootstrapping to mint the user_session_issuer, - // remote_session_issuer, and DCR-registered remote_session_client. The - // toolset it produces is reused as the toolset backend; for the - // remote backend the toolset row is harmless overhead but ensures the - // upstream-IDP wiring stays identical across backends. - base := oauthtest.CreateIssuerGatedToolset(t, ctx, conn, enc, authCtx, oauthtest.IssuerGatedToolsetOpts{ - Slug: opts.Slug, - IsPublic: opts.Visibility == mcpservers.VisibilityPublic, - UpstreamMetadata: opts.UpstreamMetadata, - RemoteSessionCallbackBaseURL: opts.RemoteSessionCallbackBaseURL, - AuthnChallengeMode: opts.AuthnChallengeMode, - }) - - mcpSlug := base.Toolset.McpSlug.String - if mcpSlug == "" { - mcpSlug = base.Toolset.Slug - } - - var ( - toolsetID uuid.NullUUID - remoteServerID uuid.NullUUID - toolsetOut *toolsets_repo.Toolset - remoteServerOut *remotemcp_repo.RemoteMcpServer - endpointSlug string - ) - - switch opts.Backend { - case issuerGatedBackendToolset: - toolsetID = uuid.NullUUID{UUID: base.Toolset.ID, Valid: true} - tk := base.Toolset - toolsetOut = &tk - endpointSlug = mcpSlug - case issuerGatedBackendRemote: - remote := remotemcptest.SeedServer(t, ctx, conn, remotemcp_repo.CreateServerParams{ - ID: uuid.New(), - ProjectID: *authCtx.ProjectID, - Name: pgtype.Text{String: "xmcp-issuer-gated-remote", Valid: true}, - Slug: pgtype.Text{String: "xmcp-issuer-gated-" + uuid.New().String()[:8], Valid: true}, - TransportType: "streamable-http", - Url: opts.RemoteUpstreamURL, - }) - remoteServerID = uuid.NullUUID{UUID: remote.ID, Valid: true} - remoteServerOut = &remote - // Remote-backed slugs intentionally don't reuse the toolset's - // mcp_slug — the /x/mcp endpoint is identified by its own slug - // and is independent of any toolset row. - endpointSlug = "xmcp-remote-" + uuid.New().String()[:8] - } - - mcpServerID, err := uuid.NewV7() - require.NoError(t, err) - mcpServer, err := mcpservers_repo.New(conn).CreateMCPServer(ctx, mcpservers_repo.CreateMCPServerParams{ - ID: mcpServerID, - ProjectID: *authCtx.ProjectID, - Name: pgtype.Text{String: "xmcp issuer-gated", Valid: true}, - Slug: pgtype.Text{String: "xmcp-issuer-gated-" + mcpServerID.String()[len(mcpServerID.String())-4:], Valid: true}, - EnvironmentID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, - UserSessionIssuerID: uuid.NullUUID{UUID: base.UserSessionIssuer.ID, Valid: true}, - RemoteMcpServerID: remoteServerID, - ToolsetID: toolsetID, - Visibility: opts.Visibility, - }) - require.NoError(t, err) - - endpoint, err := mcpendpoints_repo.New(conn).CreateMCPEndpoint(ctx, mcpendpoints_repo.CreateMCPEndpointParams{ - ProjectID: *authCtx.ProjectID, - CustomDomainID: opts.CustomDomainID, - McpServerID: uuid.NullUUID{UUID: mcpServer.ID, Valid: true}, - Slug: endpointSlug, - }) - require.NoError(t, err) - - return issuerGatedMcpServerResult{ - Slug: endpointSlug, - McpEndpoint: endpoint, - McpServer: mcpServer, - UserSessionIssuer: base.UserSessionIssuer, - RemoteSessionIssuer: base.RemoteSessionIssuer, - RemoteSessionClient: base.RemoteSessionClient, - Toolset: toolsetOut, - RemoteMcpServer: remoteServerOut, - } -} diff --git a/server/internal/xmcp/serveoauth_integration_test.go b/server/internal/xmcp/serveoauth_integration_test.go deleted file mode 100644 index 1ee354c4f2e..00000000000 --- a/server/internal/xmcp/serveoauth_integration_test.go +++ /dev/null @@ -1,348 +0,0 @@ -// serveoauth_integration_test.go drives the issuer-gated OAuth dance -// end-to-end through the chi mux built by [xmcp.Attach]: -// -// - register → authorize → consent → token → ServeMCP (no upstream IDP -// roundtrip; remote_session_client is not configured so the consent -// form posts immediately). -// - handleRemoteLoginCallback: a separate flow exercising -// /x/mcp/remote_login_callback against a live dev-idp upstream, the -// /x/mcp parallel of [mcp.TestRemoteLoginCallback_AnonymousSubject]. -// -// These tests catch route-wiring regressions in [xmcp.Attach] beyond what -// the per-adapter smoke tests do — they verify the adapter family -// composes correctly across an entire OAuth flow. -package xmcp_test - -import ( - "bytes" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" - "github.com/stretchr/testify/require" - goahttp "goa.design/goa/v3/http" - - "github.com/speakeasy-api/gram/dev-idp/pkg/devidptest" - "github.com/speakeasy-api/gram/server/internal/attr" - "github.com/speakeasy-api/gram/server/internal/cache" - "github.com/speakeasy-api/gram/server/internal/contextvalues" - "github.com/speakeasy-api/gram/server/internal/guardian" - "github.com/speakeasy-api/gram/server/internal/mcp" - "github.com/speakeasy-api/gram/server/internal/remotesessions" - remotesessions_repo "github.com/speakeasy-api/gram/server/internal/remotesessions/repo" - "github.com/speakeasy-api/gram/server/internal/testenv" - "github.com/speakeasy-api/gram/server/internal/urn" - usersessions_repo "github.com/speakeasy-api/gram/server/internal/usersessions/repo" - "github.com/speakeasy-api/gram/server/internal/xmcp" -) - -// TestAttach_OAuthFullDance_PublicRemoteBackend drives register → authorize -// → consent (GET + POST) → token → ServeMCP entirely through the chi mux -// built by [xmcp.Attach]. Uses a public visibility endpoint without an -// upstream remote_session_client so the consent form can be posted -// immediately (no IDP roundtrip required). Catches route-wiring -// regressions across the entire adapter family that the per-adapter -// smoke tests can't catch in isolation. -func TestAttach_OAuthFullDance_PublicRemoteBackend(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{}}`)) - })) - t.Cleanup(upstream.Close) - - slug, _, _ := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, upstream.URL, "public") - - mux := goahttp.NewMuxer() - xmcp.Attach(mux, ti.service, nil) - - clientRedirectURI := "http://localhost:3000/callback" - - // Step 1: POST /register — DCR mint a client. - regBody := []byte(`{"client_name":"full dance","redirect_uris":["` + clientRedirectURI + `"],"token_endpoint_auth_method":"none"}`) - regReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/x/mcp/"+slug+"/register", bytes.NewReader(regBody)) - regReq.Header.Set("Content-Type", "application/json") - regW := httptest.NewRecorder() - mux.ServeHTTP(regW, regReq) - require.Equal(t, http.StatusCreated, regW.Code, "register; body=%s", regW.Body.String()) - var regResp struct { - ClientID string `json:"client_id"` - } - require.NoError(t, json.Unmarshal(regW.Body.Bytes(), ®Resp)) - require.NotEmpty(t, regResp.ClientID) - - // Step 2: GET /authorize — public visibility 302s to /connect. - verifier := "verifier-" + uuid.NewString() - sum := sha256.Sum256([]byte(verifier)) - codeChallenge := base64.RawURLEncoding.EncodeToString(sum[:]) - - q := url.Values{} - q.Set("response_type", "code") - q.Set("client_id", regResp.ClientID) - q.Set("redirect_uri", clientRedirectURI) - q.Set("code_challenge", codeChallenge) - q.Set("code_challenge_method", "S256") - q.Set("state", "client-state") - - authReq := httptest.NewRequestWithContext(ctx, http.MethodGet, "/x/mcp/"+slug+"/authorize?"+q.Encode(), nil) - authW := httptest.NewRecorder() - mux.ServeHTTP(authW, authReq) - require.Equal(t, http.StatusFound, authW.Code, "authorize; body=%s", authW.Body.String()) - - consentLoc, err := url.Parse(authW.Header().Get("Location")) - require.NoError(t, err) - require.Contains(t, consentLoc.Path, "/x/mcp/"+slug+"/connect") - challengeID := consentLoc.Query().Get("state") - require.NotEmpty(t, challengeID) - - // Step 3: GET /connect — renders the consent form with a CSRF token - // embedded as a hidden input. - consentGetReq := httptest.NewRequestWithContext(ctx, http.MethodGet, consentLoc.String(), nil) - consentGetW := httptest.NewRecorder() - mux.ServeHTTP(consentGetW, consentGetReq) - require.Equal(t, http.StatusOK, consentGetW.Code, "consent GET; body=%s", consentGetW.Body.String()) - csrfToken := extractCSRFToken(t, consentGetW.Body.Bytes()) - require.NotEmpty(t, csrfToken) - - // Step 4: POST /connect — approve consent, mint a code, 302 back to - // the client's redirect_uri with `code=` + the original `state`. - consentForm := url.Values{} - consentForm.Set("state", challengeID) - consentForm.Set("csrf_token", csrfToken) - consentForm.Set("action", "approve") - consentPostReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/x/mcp/"+slug+"/connect", strings.NewReader(consentForm.Encode())) - consentPostReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - consentPostW := httptest.NewRecorder() - mux.ServeHTTP(consentPostW, consentPostReq) - // 303 See Other is the canonical POST-redirect-GET response for - // the consent submission. - require.Equal(t, http.StatusSeeOther, consentPostW.Code, "consent POST; body=%s", consentPostW.Body.String()) - - redirectLoc, err := url.Parse(consentPostW.Header().Get("Location")) - require.NoError(t, err) - require.Equal(t, "client-state", redirectLoc.Query().Get("state")) - code := redirectLoc.Query().Get("code") - require.NotEmpty(t, code, "consent POST must mint an auth code; redirect=%s", redirectLoc.String()) - - // Step 5: POST /token — exchange the code for an access token. - tokenForm := url.Values{} - tokenForm.Set("grant_type", "authorization_code") - tokenForm.Set("code", code) - tokenForm.Set("redirect_uri", clientRedirectURI) - tokenForm.Set("client_id", regResp.ClientID) - tokenForm.Set("code_verifier", verifier) - tokenReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/x/mcp/"+slug+"/token", strings.NewReader(tokenForm.Encode())) - tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - tokenW := httptest.NewRecorder() - mux.ServeHTTP(tokenW, tokenReq) - require.Equal(t, http.StatusOK, tokenW.Code, "token; body=%s", tokenW.Body.String()) - - var tokResp struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - } - require.NoError(t, json.Unmarshal(tokenW.Body.Bytes(), &tokResp)) - require.NotEmpty(t, tokResp.AccessToken) - require.Equal(t, "Bearer", tokResp.TokenType) - - // Step 6: POST /x/mcp/{slug} with the Bearer — proxied upstream. - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(tokResp.AccessToken), []byte(initializeBody)) - require.Equal(t, http.StatusOK, rr.Code, "ServeMCP after full dance; body=%s", rr.Body.String()) -} - -// TestHandleRemoteLoginCallback_AnonymousSubject covers the /x/mcp -// remote-login callback handler against a live dev-idp upstream and the -// xmcptest-created issuer/client trio. Mirrors -// [mcp.TestRemoteLoginCallback_AnonymousSubject] for the /x/mcp surface, -// closing the zero-coverage gap on [Service.handleRemoteLoginCallback]. -// The mounted route under /x/mcp/remote_login_callback delegates to -// mcp.Service.HandleRemoteLoginCallback; this test exercises that -// delegation through the chi mux end-to-end so a routing or RouteBase -// regression would be observed. -func TestHandleRemoteLoginCallback_AnonymousSubject(t *testing.T) { - t.Parallel() - - idp := devidptest.Launch(t, devidptest.LaunchOpts{}) - ctx, ti := newTestService(t) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - result := createIssuerGatedMcpServer(t, ctx, ti.conn, ti.enc, authCtx, issuerGatedMcpServerOpts{ - Backend: issuerGatedBackendRemote, - Slug: "xmcp-rlc-anon", - Visibility: "public", - UpstreamMetadata: idp.OAuth21Metadata(t), - RemoteSessionCallbackBaseURL: ti.serverURL.String(), - RemoteUpstreamURL: "https://upstream.invalid/mcp", - }) - - mgr, authnCache := buildXmcpChallengeManagerForTest(t, ti) - - parentID := uuid.NewString() - anonymousSubject := urn.NewAnonymousSubject(uuid.NewString()) - require.NoError(t, authnCache.Store(ctx, mcp.AuthnChallengeState{ - ID: parentID, - UserSessionIssuerID: result.UserSessionIssuer.ID, - Endpoint: mcp.EndpointRef{ - McpSlug: result.Slug, - CustomDomainID: uuid.NullUUID{}, - McpServerID: uuid.NullUUID{UUID: result.McpServer.ID, Valid: true}, - RouteBase: "x/mcp", - }, - ClientID: "test-mcp-client", - RedirectURI: "http://example.com/cb", - CodeChallenge: "", - CodeChallengeMethod: "", - CSRFToken: "csrf-token", - Subject: &anonymousSubject, - CreatedAt: time.Now(), - })) - - _, err := usersessions_repo.New(ti.conn).CreateUserSessionClient(ctx, usersessions_repo.CreateUserSessionClientParams{ - UserSessionIssuerID: result.UserSessionIssuer.ID, - ClientID: "test-mcp-client", - ClientSecretHash: pgtype.Text{Valid: false}, - ClientName: "test-mcp-client", - RedirectUris: []string{"http://example.com/cb"}, - ClientSecretExpiresAt: pgtype.Timestamptz{Valid: false}, - TokenEndpointAuthMethod: "none", - }) - require.NoError(t, err) - - clients, err := mgr.ListClients(ctx, result.McpServer.ProjectID, authCtx.ActiveOrganizationID, result.UserSessionIssuer.ID) - require.NoError(t, err) - require.Len(t, clients, 1) - - parent := remotesessions.ParentChallenge{ - ID: parentID, - ProjectID: result.McpServer.ProjectID, - UserSessionIssuerID: result.UserSessionIssuer.ID, - Subject: &anonymousSubject, - McpSlug: result.Slug, - RouteBase: "x/mcp", - FinalRedirectURI: "", - } - authURL, err := mgr.BuildAuthorizationUrl(ctx, parent, clients[0]) - require.NoError(t, err) - - upstreamResp := httpGetNoFollow(t, authURL) - defer func() { _ = upstreamResp.Body.Close() }() - require.Equal(t, http.StatusFound, upstreamResp.StatusCode, "upstream /authorize should redirect") - - loc, err := url.Parse(upstreamResp.Header.Get("Location")) - require.NoError(t, err) - code := loc.Query().Get("code") - state := loc.Query().Get("state") - require.NotEmpty(t, code) - require.NotEmpty(t, state) - - // Hit the /x/mcp/remote_login_callback route through the chi mux - // built by [xmcp.Attach] — the whole point of this test is to - // exercise the route-wiring, not the inner handler directly. - mux := goahttp.NewMuxer() - xmcp.Attach(mux, ti.service, nil) - - cbReq := httptest.NewRequestWithContext(ctx, http.MethodGet, - "/x/mcp/remote_login_callback?code="+url.QueryEscape(code)+"&state="+url.QueryEscape(state), nil) - cbW := httptest.NewRecorder() - mux.ServeHTTP(cbW, cbReq) - require.Equal(t, http.StatusSeeOther, cbW.Code, "callback adapter must redirect; body=%s", cbW.Body.String()) - require.Contains(t, cbW.Header().Get("Location"), "/x/mcp/"+result.Slug+"/connect", - "callback should redirect back to /x/mcp consent (not /mcp/) — RouteBase regression check") - require.Contains(t, cbW.Header().Get("Location"), parentID) - - sessions, err := remotesessions_repo.New(ti.conn).ListRemoteSessionsByProjectID(ctx, remotesessions_repo.ListRemoteSessionsByProjectIDParams{ - ProjectID: result.McpServer.ProjectID, - LimitValue: 10, - }) - require.NoError(t, err) - require.Len(t, sessions, 1, "exactly one remote_sessions row should exist after callback") - require.Equal(t, anonymousSubject.String(), sessions[0].RemoteSession.SubjectUrn.String()) -} - -// extractCSRFToken yanks the csrf_token hidden input value out of the -// consent-form HTML. The template stamps a literal -// `` so a string -// scan is sufficient; a real parser would be overkill for a test -// helper. -func extractCSRFToken(t *testing.T, body []byte) string { - t.Helper() - - const marker = `name="csrf_token" value="` - idx := bytes.Index(body, []byte(marker)) - if idx < 0 { - // The template orders attributes value-first in some variants — - // try the alternate ordering. - const alt = `value="` - valueIdx := bytes.Index(body, []byte(`name="csrf_token"`)) - require.GreaterOrEqual(t, valueIdx, 0, "csrf_token input missing from consent body: %s", string(body)) - // Search back from the name= for the nearest value= - segment := body[:valueIdx] - valStart := bytes.LastIndex(segment, []byte(alt)) - require.GreaterOrEqual(t, valStart, 0) - valStart += len(alt) - end := bytes.IndexByte(segment[valStart:], '"') - require.GreaterOrEqual(t, end, 0) - return string(segment[valStart : valStart+end]) - } - start := idx + len(marker) - end := bytes.IndexByte(body[start:], '"') - require.GreaterOrEqual(t, end, 0) - return string(body[start : start+end]) -} - -// httpGetNoFollow issues a GET with redirects disabled so the test can -// observe the 302 from the upstream IDP. Mirrors the same helper in the -// /mcp integration tests. -func httpGetNoFollow(t *testing.T, urlStr string) *http.Response { - t.Helper() - client := &http.Client{ - CheckRedirect: func(_ *http.Request, _ []*http.Request) error { - return http.ErrUseLastResponse - }, - } - req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, urlStr, nil) - require.NoError(t, err) - resp, err := client.Do(req) - require.NoError(t, err) - return resp -} - -// buildXmcpChallengeManagerForTest is the /x/mcp companion of the -// /mcp test helper of the same shape. Constructs a ChallengeManager -// wired to the same Redis + DB as the service under test, and a -// TypedCacheObject for AuthnChallengeState keyed identically to the -// service's internal cache. -func buildXmcpChallengeManagerForTest( - t *testing.T, - ti *testInstance, -) (*remotesessions.ChallengeManager, cache.TypedCacheObject[mcp.AuthnChallengeState]) { - t.Helper() - - policy, err := guardian.NewUnsafePolicy(ti.tracerProvider, []string{}) - require.NoError(t, err) - - mgr := remotesessions.NewChallengeManager(ti.logger, ti.tracerProvider, testenv.NewMeterProvider(t), ti.conn, ti.enc, policy, ti.cacheAdapter, ti.serverURL) - authnCache := cache.NewTypedObjectCache[mcp.AuthnChallengeState]( - ti.logger.With(attr.SlogCacheNamespace("authn_challenge")), - ti.cacheAdapter, - cache.SuffixNone, - ) - return mgr, authnCache -} diff --git a/server/internal/xmcp/serveruntime_test.go b/server/internal/xmcp/serveruntime_test.go deleted file mode 100644 index c03e57f04b1..00000000000 --- a/server/internal/xmcp/serveruntime_test.go +++ /dev/null @@ -1,1464 +0,0 @@ -package xmcp_test - -import ( - "bytes" - "context" - "crypto/sha256" - "encoding/base64" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "net/url" - "strings" - "testing" - "time" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" - "github.com/stretchr/testify/require" - goahttp "goa.design/goa/v3/http" - - "github.com/speakeasy-api/gram/server/internal/auth" - "github.com/speakeasy-api/gram/server/internal/cache" - "github.com/speakeasy-api/gram/server/internal/contextvalues" - "github.com/speakeasy-api/gram/server/internal/killswitches/mcptoolexecution" - "github.com/speakeasy-api/gram/server/internal/mcp" - mcpendpointsrepo "github.com/speakeasy-api/gram/server/internal/mcpendpoints/repo" - mcpserversrepo "github.com/speakeasy-api/gram/server/internal/mcpservers/repo" - "github.com/speakeasy-api/gram/server/internal/oops" - organizationsrepo "github.com/speakeasy-api/gram/server/internal/organizations/repo" - projectsrepo "github.com/speakeasy-api/gram/server/internal/projects/repo" - remotemcprepo "github.com/speakeasy-api/gram/server/internal/remotemcp/repo" - "github.com/speakeasy-api/gram/server/internal/testenv/testrepo" - "github.com/speakeasy-api/gram/server/internal/testmcp" - toolsetsrepo "github.com/speakeasy-api/gram/server/internal/toolsets/repo" - "github.com/speakeasy-api/gram/server/internal/urn" - usersessionsrepo "github.com/speakeasy-api/gram/server/internal/usersessions/repo" - "github.com/speakeasy-api/gram/server/internal/xmcp" -) - -const initializeBody = `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"test","version":"1.0.0"}}}` - -// runHandlerWithHeaders is a generalized variant of runHandler that threads -// extra request headers (e.g. Mcp-Session-Id) onto the test request. -func runHandlerWithHeaders(t *testing.T, ctx context.Context, ti *testInstance, method, slug, authorization string, body []byte, extraHeaders map[string]string) *httptest.ResponseRecorder { - t.Helper() - - mux := chi.NewMux() - mux.MethodFunc(method, xmcp.RuntimePath, oops.ErrHandle(ti.logger, ti.service.ServeMCP).ServeHTTP) - - req := httptest.NewRequestWithContext(ctx, method, "/x/mcp/"+slug, bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - // MCP Streamable HTTP § Sending Messages to the Server (step 2) - // requires clients to list both application/json and text/event-stream - // on POST; testmcp's SDK-backed server enforces this. - req.Header.Set("Accept", "application/json, text/event-stream") - if authorization != "" { - req.Header.Set("Authorization", authorization) - } - for k, v := range extraHeaders { - req.Header.Set(k, v) - } - - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - return w -} - -// insertProject creates a stub project row so we can test cross-project -// isolation and returns its id for use as a foreign key on remote_mcp_servers. -func insertProject(t *testing.T, ctx context.Context, ti *testInstance, organizationID string) uuid.UUID { - t.Helper() - - slug := "other-project-" + uuid.NewString()[:8] - p, err := projectsrepo.New(ti.conn).CreateProject(ctx, projectsrepo.CreateProjectParams{ - Name: slug, - Slug: slug, - OrganizationID: organizationID, - }) - require.NoError(t, err) - return p.ID -} - -func TestServeMCP_SlugNotFoundReturns404(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - rr := runHandler(t, ctx, ti, http.MethodPost, "no-such-slug", "", []byte(initializeBody)) - require.Equal(t, http.StatusNotFound, rr.Code) -} - -func TestServeMCP_PrivateRemoteBackend_MissingAuthReturns401(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - mockServer := testmcp.NewStreamableHTTPServer(t, &testmcp.Server{Tools: nil}) - t.Cleanup(mockServer.Close) - - slug, _, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, mockServer.URL, "private") - - rr := runHandler(t, ctx, ti, http.MethodPost, slug, "", []byte(initializeBody)) - require.Equal(t, http.StatusUnauthorized, rr.Code) -} - -func TestServeMCP_PrivateRemoteBackend_InvalidAuthReturns401(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - mockServer := testmcp.NewStreamableHTTPServer(t, &testmcp.Server{Tools: nil}) - t.Cleanup(mockServer.Close) - - slug, _, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, mockServer.URL, "private") - - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer("gram_test_not_a_real_key"), []byte(initializeBody)) - require.Equal(t, http.StatusUnauthorized, rr.Code) -} - -func TestServeMCP_DisabledReturns404(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - mockServer := testmcp.NewStreamableHTTPServer(t, &testmcp.Server{Tools: nil}) - t.Cleanup(mockServer.Close) - - // Even with valid auth a disabled server should look exactly like a - // missing one — visibility is the runtime kill-switch. - slug, _, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, mockServer.URL, "disabled") - key := seedAPIKey(t, ctx, ti, authCtx.ActiveOrganizationID, authCtx.UserID, authCtx.ProjectID, []string{auth.APIKeyScopeConsumer.String()}) - - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(key), []byte(initializeBody)) - require.Equal(t, http.StatusNotFound, rr.Code) -} - -// An unauthenticated caller gets the OAuth challenge even on a public -// server; the authenticated pass-through is covered by the test below. -func TestServeMCP_PublicRemoteBackend_UnauthenticatedChallenged(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - mockServer := testmcp.NewStreamableHTTPServer(t, &testmcp.Server{Tools: nil}) - t.Cleanup(mockServer.Close) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, mockServer.URL, "public") - - rr := runHandler(t, ctx, ti, http.MethodPost, slug, "", []byte(initializeBody)) - require.Equal(t, http.StatusUnauthorized, rr.Code) - require.NotEmpty(t, rr.Header().Get("WWW-Authenticate")) -} - -// TestServeMCP_PublicRemoteBackend_IssuerTokenForwardsUpstream: with a minted -// issuer-gated bearer, a public remote-backed request proxies through and the -// upstream Mcp-Session-Id is relayed back to the caller. -func TestServeMCP_PublicRemoteBackend_IssuerTokenForwardsUpstream(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - mockServer := testmcp.NewStreamableHTTPServer(t, &testmcp.Server{Tools: nil}) - t.Cleanup(mockServer.Close) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, mcpServer, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, mockServer.URL, "public") - token := mintAccessTokenForSeededEndpoint(t, ctx, ti, slug, mcpServer) - - initResp := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(token), []byte(initializeBody)) - require.Equal(t, http.StatusOK, initResp.Code, "initialize body=%s", initResp.Body.String()) - - sessionID := initResp.Header().Get("Mcp-Session-Id") - require.NotEmpty(t, sessionID, "proxy must relay Mcp-Session-Id from upstream") -} - -// The issuer gate accepts only user-session JWTs, so a Gram API key — even -// one in the server's own org — is rejected with an OAuth challenge. -func TestServeMCP_PrivateRemoteBackend_APIKeyRejectedWithChallenge(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - mockServer := testmcp.NewStreamableHTTPServer(t, &testmcp.Server{Tools: nil}) - t.Cleanup(mockServer.Close) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, mockServer.URL, "private") - key := seedAPIKey(t, ctx, ti, authCtx.ActiveOrganizationID, authCtx.UserID, authCtx.ProjectID, []string{auth.APIKeyScopeConsumer.String()}) - - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(key), []byte(initializeBody)) - require.Equal(t, http.StatusUnauthorized, rr.Code, "body=%s", rr.Body.String()) - require.NotEmpty(t, rr.Header().Get("WWW-Authenticate")) -} - -func TestServeMCP_PublicRemoteBackend_AppliesStaticSecretHeader(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - var gotAPIKey string - done := make(chan struct{}, 1) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAPIKey = r.Header.Get("X-Upstream-Api-Key") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{}}`)) - done <- struct{}{} - })) - t.Cleanup(upstream.Close) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, mcpServer, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, upstream.URL, "public", remotemcprepo.CreateServerHeaderParams{ - Name: "X-Upstream-Api-Key", - Value: pgtype.Text{String: "upstream-secret", Valid: true}, - IsRequired: true, - IsSecret: true, - }) - token := mintAccessTokenForSeededEndpoint(t, ctx, ti, slug, mcpServer) - - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(token), []byte(initializeBody)) - <-done - require.Equal(t, http.StatusOK, rr.Code) - require.Equal(t, "upstream-secret", gotAPIKey, "secret static header must be decrypted and forwarded") -} - -func TestServeMCP_PublicRemoteBackend_DeleteForwardsSessionTermination(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - var gotMethod, gotSession string - done := make(chan struct{}, 1) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method - gotSession = r.Header.Get("Mcp-Session-Id") - w.WriteHeader(http.StatusNoContent) - done <- struct{}{} - })) - t.Cleanup(upstream.Close) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, mcpServer, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, upstream.URL, "public") - token := mintAccessTokenForSeededEndpoint(t, ctx, ti, slug, mcpServer) - - rr := runHandlerWithHeaders(t, ctx, ti, http.MethodDelete, slug, bearer(token), nil, map[string]string{"Mcp-Session-Id": "abc-session"}) - <-done - require.Equal(t, http.StatusNoContent, rr.Code) - require.Equal(t, http.MethodDelete, gotMethod) - require.Equal(t, "abc-session", gotSession) -} - -// A Gram API key is rejected at the issuer gate, so no request — and -// therefore no Gram credential — ever reaches the upstream. -func TestServeMCP_PrivateRemoteBackend_APIKeyNeverReachesUpstream(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - upstreamCalled := false - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - upstreamCalled = true - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{}}`)) - })) - t.Cleanup(upstream.Close) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, upstream.URL, "private") - key := seedAPIKey(t, ctx, ti, authCtx.ActiveOrganizationID, authCtx.UserID, authCtx.ProjectID, []string{auth.APIKeyScopeConsumer.String()}) - - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(key), []byte(initializeBody)) - require.Equal(t, http.StatusUnauthorized, rr.Code, "body=%s", rr.Body.String()) - require.False(t, upstreamCalled, "Gram API key must never leak to the remote MCP server") -} - -// Public mcp_server with an issuer-gated bearer: the proxy strips the inbound -// Authorization, so the upstream sees none (the issuer has no -// remote_session_clients bound, so there is nothing to forward either). -func TestServeMCP_PublicRemoteBackend_IssuerTokenSendsNoAuthorizationUpstream(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - var gotAuth string - done := make(chan struct{}, 1) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotAuth = r.Header.Get("Authorization") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{}}`)) - done <- struct{}{} - })) - t.Cleanup(upstream.Close) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, mcpServer, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, upstream.URL, "public") - token := mintAccessTokenForSeededEndpoint(t, ctx, ti, slug, mcpServer) - - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(token), []byte(initializeBody)) - <-done - require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) - require.Empty(t, gotAuth, "issuer-gated bearer must never leak to the remote MCP server") -} - -// Public variant of the API-key rejection above. -func TestServeMCP_PublicRemoteBackend_GramAPIKeyNeverReachesUpstream(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - upstreamCalled := false - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - upstreamCalled = true - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{}}`)) - })) - t.Cleanup(upstream.Close) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, upstream.URL, "public") - key := seedAPIKey(t, ctx, ti, authCtx.ActiveOrganizationID, authCtx.UserID, authCtx.ProjectID, []string{auth.APIKeyScopeConsumer.String()}) - - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(key), []byte(initializeBody)) - require.Equal(t, http.StatusUnauthorized, rr.Code, "body=%s", rr.Body.String()) - require.False(t, upstreamCalled, "Gram API key must never leak even on a public mcp_server") -} - -// The gate only accepts user-session JWTs, so org membership never enters -// the picture: same-org cross-project API keys are rejected too. -func TestServeMCP_PrivateRemoteBackend_SameOrgCrossProjectAPIKeyRejected(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - mockServer := testmcp.NewStreamableHTTPServer(t, &testmcp.Server{Tools: nil}) - t.Cleanup(mockServer.Close) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - - otherProjectID := insertProject(t, ctx, ti, authCtx.ActiveOrganizationID) - slug, _, _ := seedRemoteMCPEndpoint(t, ctx, ti, otherProjectID, mockServer.URL, "private") - key := seedAPIKey(t, ctx, ti, authCtx.ActiveOrganizationID, authCtx.UserID, authCtx.ProjectID, []string{auth.APIKeyScopeConsumer.String()}) - - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(key), []byte(initializeBody)) - require.Equal(t, http.StatusUnauthorized, rr.Code, "body=%s", rr.Body.String()) -} - -// A foreign org's API key gets 401 just like a same-org one. -func TestServeMCP_PrivateRemoteBackend_CrossOrgReturns401(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - mockServer := testmcp.NewStreamableHTTPServer(t, &testmcp.Server{Tools: nil}) - t.Cleanup(mockServer.Close) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - - otherOrgID := "org_" + uuid.NewString() - _, err := organizationsrepo.New(ti.conn).UpsertOrganizationMetadata(ctx, organizationsrepo.UpsertOrganizationMetadataParams{ - ID: otherOrgID, - Name: "other-org", - Slug: "other-org-" + uuid.NewString()[:8], - WorkosID: pgtype.Text{}, - Whitelisted: pgtype.Bool{}, - }) - require.NoError(t, err) - - otherProjectID := insertProject(t, ctx, ti, otherOrgID) - slug, _, _ := seedRemoteMCPEndpoint(t, ctx, ti, otherProjectID, mockServer.URL, "private") - - // API key is in the original (caller's) org; mcp_server is in a foreign - // org. The issuer gate rejects the non-JWT bearer outright. - key := seedAPIKey(t, ctx, ti, authCtx.ActiveOrganizationID, authCtx.UserID, authCtx.ProjectID, []string{auth.APIKeyScopeConsumer.String()}) - - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(key), []byte(initializeBody)) - require.Equal(t, http.StatusUnauthorized, rr.Code) -} - -// TestServeMCP_IssuerGatedRemoteBackend_MissingAuthEmitsChallenge verifies -// that an issuer-gated /x/mcp remote-backed request without a valid -// Authorization header receives 401 + a WWW-Authenticate header whose -// resource_metadata URL points at /.well-known/oauth-protected-resource/x/mcp/ -// — exactly what a spec-compliant MCP client constructs from a resource -// URL of /x/mcp/. -func TestServeMCP_IssuerGatedRemoteBackend_MissingAuthEmitsChallenge(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, _ := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public") - - rr := runHandler(t, ctx, ti, http.MethodPost, slug, "", []byte(initializeBody)) - require.Equal(t, http.StatusUnauthorized, rr.Code) - - wwwAuth := rr.Header().Get("WWW-Authenticate") - require.NotEmpty(t, wwwAuth) - - expectedResourceMetadataURL := "http://0.0.0.0/.well-known/oauth-protected-resource/x/mcp/" + slug - require.Equal(t, fmt.Sprintf(`Bearer resource_metadata="%s"`, expectedResourceMetadataURL), wwwAuth) -} - -// TestServeMCP_IssuerGatedRFC9728Invariant asserts the RFC 9728 §5.3 / §3 -// contract between the ServeMCP challenge and the well-known protected- -// resource metadata: the resource_metadata URL embedded in -// WWW-Authenticate must string-equal the metadata response's `resource` -// field (and its `authorization_servers[0]` entry's RFC 9728 prefix -// path). A drift here breaks spec-compliant MCP-client discovery, which -// follows the WWW-Authenticate header to fetch the metadata document. -func TestServeMCP_IssuerGatedRFC9728Invariant(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, _ := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public") - - // Capture WWW-Authenticate's resource_metadata URL from an unauthenticated - // ServeMCP request. - rr := runHandler(t, ctx, ti, http.MethodPost, slug, "", []byte(initializeBody)) - require.Equal(t, http.StatusUnauthorized, rr.Code) - wwwAuth := rr.Header().Get("WWW-Authenticate") - require.NotEmpty(t, wwwAuth) - expectedWWW := fmt.Sprintf(`Bearer resource_metadata="http://0.0.0.0/.well-known/oauth-protected-resource/x/mcp/%s"`, slug) - require.Equal(t, expectedWWW, wwwAuth) - - // Fetch the protected-resource metadata and confirm its `resource` - // field is the same `/x/mcp/` URL the WWW-Authenticate - // resource_metadata URL is keyed under. - w, err := runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthProtectedResourceMetadata, "/.well-known/oauth-protected-resource/x/mcp/"+slug, slug) - require.NoError(t, err) - require.Equal(t, http.StatusOK, w.Code) - var metadata map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &metadata)) - require.Equal(t, "http://0.0.0.0/x/mcp/"+slug, metadata["resource"]) -} - -// TestServeMCP_IssuerGatedRemoteBackend_HappyPath drives the full -// post-consent half of the OAuth flow for an /x/mcp issuer-gated -// remote-backed mcp_server and verifies that the minted bearer token -// authorises a subsequent ServeMCP request to be proxied upstream. -// -// We seed the post-consent state directly (user_session_clients row + -// UserSessionGrant in Redis) rather than driving register → authorize → -// consent → token through HTTP, because the upstream review specifically -// asked for the token-mint and bearer-use legs — the consent-and-earlier -// path is already covered by authnchallenge_test.go on the /mcp side and -// the adapters in xmcp/service.go are thin slug-resolution shims that -// delegate to the same mcp.Service.Serve* methods. -func TestServeMCP_IssuerGatedRemoteBackend_HappyPath(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - // Upstream stub that records the forwarded request and returns a - // well-formed JSON-RPC initialize response. - var ( - gotMethod string - gotAuth string - ) - done := make(chan struct{}, 1) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method - gotAuth = r.Header.Get("Authorization") - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{}}`)) - done <- struct{}{} - })) - t.Cleanup(upstream.Close) - - // Seed the issuer-gated /x/mcp endpoint and look up its - // organization id (mcp_servers doesn't carry org id directly — - // NewResolvedMcpEndpointFromMcpServer needs it threaded in). - slug, mcpServer, issuerID := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, upstream.URL, "public") - mcpEndpoint, err := mcpendpointsrepo.New(ti.conn).GetMCPEndpointByCustomDomainAndSlug(ctx, mcpendpointsrepo.GetMCPEndpointByCustomDomainAndSlugParams{ - Slug: slug, - CustomDomainID: uuid.NullUUID{}, - }) - require.NoError(t, err) - project, err := projectsrepo.New(ti.conn).GetProjectByID(ctx, *authCtx.ProjectID) - require.NoError(t, err) - endpoint := mcp.NewResolvedMcpEndpointFromMcpServer(&mcpEndpoint, &mcpServer, project.OrganizationID, "x/mcp") - - // Public OAuth client (token_endpoint_auth_method=none) — no - // client_secret_hash, PKCE alone establishes proof-of-possession. - clientID := "test-client-" + uuid.NewString() - redirectURI := "http://localhost:3000/callback" - _, err = usersessionsrepo.New(ti.conn).CreateUserSessionClient(ctx, usersessionsrepo.CreateUserSessionClientParams{ - UserSessionIssuerID: issuerID, - ClientID: clientID, - ClientName: "happy-path test client", - RedirectUris: []string{redirectURI}, - TokenEndpointAuthMethod: "none", - }) - require.NoError(t, err) - - // Seed a UserSessionGrant directly — what HandleConsent's POST - // would have written after the user clicked "approve". The - // anonymous subject matches the visibility=public flow: - // HandleAuthorize stamps urn:gram:anonymous: on public - // endpoints instead of round-tripping through the IDP. - verifier := "verifier-" + uuid.NewString() - sum := sha256.Sum256([]byte(verifier)) - codeChallenge := base64.RawURLEncoding.EncodeToString(sum[:]) - code := "auth-code-" + uuid.NewString() - subject := urn.NewAnonymousSubject(uuid.NewString()) - grantCache := cache.NewTypedObjectCache[mcp.UserSessionGrant](ti.logger, ti.cacheAdapter, cache.SuffixNone) - require.NoError(t, grantCache.Store(ctx, mcp.UserSessionGrant{ - Code: code, - UserSessionIssuerID: issuerID, - UserSessionClientID: uuid.Nil, - ClientID: clientID, - RedirectURI: redirectURI, - CodeChallenge: codeChallenge, - CodeChallengeMethod: "S256", - Subject: subject, - CreatedAt: time.Now(), - })) - - // Drive ServeToken directly with the auth_code grant. - // mcp.Service.ServeToken is shared by /mcp and /x/mcp handler - // adapters; the xmcp adapter is a slug-resolution shim that calls - // into this same method, so exercising ServeToken with a - // hand-built ResolvedMcpEndpoint covers the same code path the - // /x/mcp/{slug}/token route runs end-to-end. - tokenForm := url.Values{} - tokenForm.Set("grant_type", "authorization_code") - tokenForm.Set("code", code) - tokenForm.Set("redirect_uri", redirectURI) - tokenForm.Set("client_id", clientID) - tokenForm.Set("code_verifier", verifier) - tokenReq := httptest.NewRequestWithContext(ctx, http.MethodPost, "/x/mcp/"+slug+"/token", strings.NewReader(tokenForm.Encode())) - tokenReq.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rctx := chi.NewRouteContext() - rctx.URLParams.Add("mcpSlug", slug) - tokenReq = tokenReq.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) - - tokenW := httptest.NewRecorder() - require.NoError(t, ti.mcpService.ServeToken(tokenW, tokenReq, endpoint)) - require.Equal(t, http.StatusOK, tokenW.Code, "token endpoint should mint an access token: %s", tokenW.Body.String()) - - var tokenResp struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - } - require.NoError(t, json.Unmarshal(tokenW.Body.Bytes(), &tokenResp)) - require.NotEmpty(t, tokenResp.AccessToken) - require.Equal(t, "Bearer", tokenResp.TokenType) - - // Use the minted bearer against ServeMCP. The issuer gate accepts - // the JWT and the request proxies through to the upstream stub, - // which records the forwarded Authorization header (the proxy - // always strips the inbound Authorization — empty here because - // the issuer has no remote_session_clients bound). - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(tokenResp.AccessToken), []byte(initializeBody)) - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatalf("upstream not invoked within 5s; status=%d body=%s", rr.Code, rr.Body.String()) - } - require.Equal(t, http.StatusOK, rr.Code, "ServeMCP should proxy through; body=%s", rr.Body.String()) - require.Equal(t, http.MethodPost, gotMethod) - require.Empty(t, gotAuth, "remote proxy strips inbound Authorization; no upstream remote_session is configured") -} - -func mintAccessTokenForSeededEndpoint( - t *testing.T, - ctx context.Context, - ti *testInstance, - slug string, - mcpServer mcpserversrepo.McpServer, -) string { - t.Helper() - - mcpEndpoint, err := mcpendpointsrepo.New(ti.conn).GetMCPEndpointByCustomDomainAndSlug(ctx, mcpendpointsrepo.GetMCPEndpointByCustomDomainAndSlugParams{ - Slug: slug, - CustomDomainID: uuid.NullUUID{}, - }) - require.NoError(t, err) - project, err := projectsrepo.New(ti.conn).GetProjectByID(ctx, mcpServer.ProjectID) - require.NoError(t, err) - endpoint := mcp.NewResolvedMcpEndpointFromMcpServer(&mcpEndpoint, &mcpServer, project.OrganizationID, "x/mcp") - - require.True(t, mcpServer.UserSessionIssuerID.Valid, "remote-backed seeds always carry an issuer") - subject := urn.NewAnonymousSubject(uuid.NewString()) - return mintIssuerGatedAccessToken(t, ctx, ti, slug, endpoint, mcpServer.UserSessionIssuerID.UUID, subject) -} - -// mintIssuerGatedAccessToken drives ServeToken with a synthesised -// UserSessionGrant against the given endpoint and returns the minted -// JWT. Used by happy-path tests that need a bearer to exercise the -// post-gate code paths without driving register → authorize → consent -// over real HTTP. The grant is keyed by a fresh code per call so -// parallel tests don't race. -func mintIssuerGatedAccessToken( - t *testing.T, - ctx context.Context, - ti *testInstance, - slug string, - endpoint *mcp.ResolvedMcpEndpoint, - issuerID uuid.UUID, - subject urn.SessionSubject, -) string { - t.Helper() - - clientID := "test-client-" + uuid.NewString() - redirectURI := "http://localhost:3000/callback" - _, err := usersessionsrepo.New(ti.conn).CreateUserSessionClient(ctx, usersessionsrepo.CreateUserSessionClientParams{ - UserSessionIssuerID: issuerID, - ClientID: clientID, - ClientName: "issuer-gated test client", - RedirectUris: []string{redirectURI}, - TokenEndpointAuthMethod: "none", - }) - require.NoError(t, err) - - verifier := "verifier-" + uuid.NewString() - sum := sha256.Sum256([]byte(verifier)) - codeChallenge := base64.RawURLEncoding.EncodeToString(sum[:]) - code := "auth-code-" + uuid.NewString() - grantCache := cache.NewTypedObjectCache[mcp.UserSessionGrant](ti.logger, ti.cacheAdapter, cache.SuffixNone) - require.NoError(t, grantCache.Store(ctx, mcp.UserSessionGrant{ - Code: code, - UserSessionIssuerID: issuerID, - UserSessionClientID: uuid.Nil, - ClientID: clientID, - RedirectURI: redirectURI, - CodeChallenge: codeChallenge, - CodeChallengeMethod: "S256", - Subject: subject, - CreatedAt: time.Now(), - })) - - form := url.Values{} - form.Set("grant_type", "authorization_code") - form.Set("code", code) - form.Set("redirect_uri", redirectURI) - form.Set("client_id", clientID) - form.Set("code_verifier", verifier) - req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/x/mcp/"+slug+"/token", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - rctx := chi.NewRouteContext() - rctx.URLParams.Add("mcpSlug", slug) - req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx)) - - w := httptest.NewRecorder() - require.NoError(t, ti.mcpService.ServeToken(w, req, endpoint)) - require.Equal(t, http.StatusOK, w.Code, "token endpoint should mint an access token: %s", w.Body.String()) - - var resp struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - require.NotEmpty(t, resp.AccessToken) - require.Equal(t, "Bearer", resp.TokenType) - return resp.AccessToken -} - -// TestServeMCP_IssuerGatedToolsetBackend_HappyPath verifies the -// toolset-backed companion to TestServeMCP_IssuerGatedRemoteBackend_HappyPath. -// A bearer minted against the mcp_server's issuer must authorise a -// subsequent ServeMCP request to dispatch through the toolset-backed -// runtime path (no upstream remote MCP server in this case — the -// initialize response comes from Gram itself). -// -// Catches the symmetric bug of the one fixed in serveRemoteBackend: -// inside ServeToolsetResolved the legacy auth chain is skipped on -// !issuerGated, but issuerGated is computed as -// `toolset.UserSessionIssuerID.Valid && !skipIssuerGate`. When /x/mcp -// passes skipIssuerGate=true (the caller already gated), the legacy -// chain runs and rejects the JWT. -func TestServeMCP_IssuerGatedToolsetBackend_HappyPath(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, mcpServer, issuerID := seedIssuerGatedToolsetMCPEndpoint(t, ctx, ti, authCtx.ActiveOrganizationID, *authCtx.ProjectID, "public") - mcpEndpoint, err := mcpendpointsrepo.New(ti.conn).GetMCPEndpointByCustomDomainAndSlug(ctx, mcpendpointsrepo.GetMCPEndpointByCustomDomainAndSlugParams{ - Slug: slug, - CustomDomainID: uuid.NullUUID{}, - }) - require.NoError(t, err) - project, err := projectsrepo.New(ti.conn).GetProjectByID(ctx, *authCtx.ProjectID) - require.NoError(t, err) - endpoint := mcp.NewResolvedMcpEndpointFromMcpServer(&mcpEndpoint, &mcpServer, project.OrganizationID, "x/mcp") - - subject := urn.NewAnonymousSubject(uuid.NewString()) - accessToken := mintIssuerGatedAccessToken(t, ctx, ti, slug, endpoint, issuerID, subject) - - // Drive ServeMCP with the minted bearer. Without the legacy-auth - // skip fix, ServeToolsetResolved's legacy chain runs and 401s the - // JWT it doesn't recognise. - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(accessToken), []byte(initializeBody)) - require.NotEqual(t, http.StatusUnauthorized, rr.Code, "issuer-gated bearer must not be rejected by the legacy auth chain inside ServeToolsetResolved; body=%s", rr.Body.String()) - require.Equal(t, http.StatusOK, rr.Code, "ServeMCP should respond 200; body=%s", rr.Body.String()) -} - -func TestServeMCP_IssuerGatedToolsetBackend_Killswitch(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - require.NotEmpty(t, authCtx.UserID) - - slug, mcpServer, issuerID := seedIssuerGatedToolsetMCPEndpoint(t, ctx, ti, authCtx.ActiveOrganizationID, *authCtx.ProjectID, "public") - mcpEndpoint, err := mcpendpointsrepo.New(ti.conn).GetMCPEndpointByCustomDomainAndSlug(ctx, mcpendpointsrepo.GetMCPEndpointByCustomDomainAndSlugParams{ - Slug: slug, - CustomDomainID: uuid.NullUUID{}, - }) - require.NoError(t, err) - endpoint := mcp.NewResolvedMcpEndpointFromMcpServer(&mcpEndpoint, &mcpServer, authCtx.ActiveOrganizationID, "x/mcp") - accessToken := mintIssuerGatedAccessToken(t, ctx, ti, slug, endpoint, issuerID, urn.NewUserSubject(authCtx.UserID)) - - err = testrepo.New(ti.conn).InsertKillswitchPrescriptionFixture(ctx, testrepo.InsertKillswitchPrescriptionFixtureParams{ - PrescriptionID: uuid.New(), - OrganizationID: authCtx.ActiveOrganizationID, - DefinitionKey: string(mcptoolexecution.DefinitionKeyMCPToolExecution), - PrincipalKind: string(mcptoolexecution.PrincipalKindUser), - PrincipalKey: authCtx.UserID, - ResourceKind: string(mcptoolexecution.ResourceKindMCPServer), - ResourceScope: "selected", - InternalNote: "test context", - ExternalNote: "Exact x/mcp note.", - ResourceKeys: []string{mcpServer.ID.String()}, - }) - require.NoError(t, err) - - body := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"missing_tool","arguments":{}}}`) - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(accessToken), body) - require.Equal(t, http.StatusOK, rr.Code) - require.JSONEq(t, `{"jsonrpc":"2.0","id":2,"error":{"code":-32003,"message":"Exact x/mcp note.","data":{"code":"mcp_tool_calls_paused"}}}`, rr.Body.String()) -} - -// TestServeMCP_IssuerGatedRemoteBackend_Private_HappyPath exercises the -// private-visibility branch of serveRemoteBackend. Without the -// `if !issuerGated` guard around RequirePrivateIdentityAuth the JWT -// would be rejected the same way today's pre-fix code rejected the -// public branch. The bearer is minted against a user subject (private -// endpoints route through the IDP rather than stamping anonymous). -func TestServeMCP_IssuerGatedRemoteBackend_PrivateHappyPath(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - var gotMethod string - done := make(chan struct{}, 1) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{}}`)) - done <- struct{}{} - })) - t.Cleanup(upstream.Close) - - slug, mcpServer, issuerID := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, upstream.URL, "private") - mcpEndpoint, err := mcpendpointsrepo.New(ti.conn).GetMCPEndpointByCustomDomainAndSlug(ctx, mcpendpointsrepo.GetMCPEndpointByCustomDomainAndSlugParams{ - Slug: slug, - CustomDomainID: uuid.NullUUID{}, - }) - require.NoError(t, err) - project, err := projectsrepo.New(ti.conn).GetProjectByID(ctx, *authCtx.ProjectID) - require.NoError(t, err) - endpoint := mcp.NewResolvedMcpEndpointFromMcpServer(&mcpEndpoint, &mcpServer, project.OrganizationID, "x/mcp") - - // Private endpoints route through the IDP, which stamps a user - // subject (not anonymous) onto the cached challenge state. - subject := urn.NewUserSubject("user_" + uuid.NewString()[:8]) - accessToken := mintIssuerGatedAccessToken(t, ctx, ti, slug, endpoint, issuerID, subject) - - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(accessToken), []byte(initializeBody)) - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatalf("upstream not invoked within 5s; status=%d body=%s", rr.Code, rr.Body.String()) - } - require.NotEqual(t, http.StatusUnauthorized, rr.Code, "issuer-gated bearer on a private endpoint must not be re-rejected by RequirePrivateIdentityAuth; body=%s", rr.Body.String()) - require.Equal(t, http.StatusOK, rr.Code, "ServeMCP should proxy through; body=%s", rr.Body.String()) - require.Equal(t, http.MethodPost, gotMethod) -} - -func TestServeMCP_IssuerGatedRemoteBackend_PrivateKillswitch(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - require.NotEmpty(t, authCtx.UserID) - - upstreamCalled := make(chan struct{}, 1) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - upstreamCalled <- struct{}{} - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":2,"result":{}}`)) - })) - t.Cleanup(upstream.Close) - - slug, mcpServer, issuerID := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, upstream.URL, "private") - mcpEndpoint, err := mcpendpointsrepo.New(ti.conn).GetMCPEndpointByCustomDomainAndSlug(ctx, mcpendpointsrepo.GetMCPEndpointByCustomDomainAndSlugParams{ - Slug: slug, - CustomDomainID: uuid.NullUUID{}, - }) - require.NoError(t, err) - project, err := projectsrepo.New(ti.conn).GetProjectByID(ctx, *authCtx.ProjectID) - require.NoError(t, err) - endpoint := mcp.NewResolvedMcpEndpointFromMcpServer(&mcpEndpoint, &mcpServer, project.OrganizationID, "x/mcp") - accessToken := mintIssuerGatedAccessToken(t, ctx, ti, slug, endpoint, issuerID, urn.NewUserSubject(authCtx.UserID)) - - err = testrepo.New(ti.conn).InsertKillswitchPrescriptionFixture(ctx, testrepo.InsertKillswitchPrescriptionFixtureParams{ - PrescriptionID: uuid.New(), - OrganizationID: authCtx.ActiveOrganizationID, - DefinitionKey: string(mcptoolexecution.DefinitionKeyMCPToolExecution), - PrincipalKind: string(mcptoolexecution.PrincipalKindUser), - PrincipalKey: authCtx.UserID, - ResourceKind: string(mcptoolexecution.ResourceKindMCPServer), - ResourceScope: "selected", - InternalNote: "test context", - ExternalNote: "Exact private proxy note.", - ResourceKeys: []string{mcpServer.ID.String()}, - }) - require.NoError(t, err) - - body := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"missing_tool","arguments":{}}}`) - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(accessToken), body) - require.Equal(t, http.StatusOK, rr.Code) - require.JSONEq(t, `{"jsonrpc":"2.0","id":2,"error":{"code":-32003,"message":"Exact private proxy note.","data":{"code":"mcp_tool_calls_paused"}}}`, rr.Body.String()) - select { - case <-upstreamCalled: - t.Fatal("private killswitch rejection reached the upstream MCP server") - default: - } -} - -// TestServeMCP_IssuerGated_CrossIssuerTokenRejected asserts the -// audience-binding invariant: a bearer minted against issuer A must be -// rejected when presented at issuer B's endpoint, even if both endpoints -// are issuer-gated and otherwise structurally identical. The check -// lives inside userSessionSigner.ValidateBearer (audience claim -// equality) and is load-bearing for cross-tenant isolation. -func TestServeMCP_IssuerGatedRemoteBackend_CrossIssuerTokenRejected(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{}}`)) - })) - t.Cleanup(upstream.Close) - - // Endpoint A: where the token is minted. - slugA, mcpServerA, issuerA := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, upstream.URL, "public") - mcpEndpointA, err := mcpendpointsrepo.New(ti.conn).GetMCPEndpointByCustomDomainAndSlug(ctx, mcpendpointsrepo.GetMCPEndpointByCustomDomainAndSlugParams{ - Slug: slugA, - CustomDomainID: uuid.NullUUID{}, - }) - require.NoError(t, err) - project, err := projectsrepo.New(ti.conn).GetProjectByID(ctx, *authCtx.ProjectID) - require.NoError(t, err) - endpointA := mcp.NewResolvedMcpEndpointFromMcpServer(&mcpEndpointA, &mcpServerA, project.OrganizationID, "x/mcp") - - // Endpoint B: a sibling under a different issuer. - slugB, _, _ := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, upstream.URL, "public") - require.NotEqual(t, slugA, slugB) - - subject := urn.NewAnonymousSubject(uuid.NewString()) - accessToken := mintIssuerGatedAccessToken(t, ctx, ti, slugA, endpointA, issuerA, subject) - - // The bearer is bound to endpointA's audience URN. Presenting it - // at endpointB must 401 with the same WWW-Authenticate shape - // missing-auth requests get. - rr := runHandler(t, ctx, ti, http.MethodPost, slugB, bearer(accessToken), []byte(initializeBody)) - require.Equal(t, http.StatusUnauthorized, rr.Code, "issuer-A bearer must not authorise issuer-B endpoint; body=%s", rr.Body.String()) - wwwAuth := rr.Header().Get("WWW-Authenticate") - require.NotEmpty(t, wwwAuth) - require.Contains(t, wwwAuth, "/x/mcp/"+slugB, "challenge URL must point at endpoint B's metadata, not A's") -} - -// TestServeMCP_IssuerGated_RegisterRouteAdapter is a smoke test for the -// xmcp.Attach route wiring: it drives POST /x/mcp/{slug}/register -// through the full chi mux that xmcp.Attach builds, instead of calling -// mcp.Service.ServeRegister directly. Catches route-level integration -// bugs — wrong chi URL param name, wrong method bound, wrong adapter -// dispatched — that would otherwise slip past the post-resolution -// happy-path tests. The other OAuth adapters (authorize, consent, -// token, revoke) are structurally identical so a single smoke test -// covers the family. -func TestAttach_OAuthRegisterRoute_MintsClientID(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, _ := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public") - - mux := goahttp.NewMuxer() - xmcp.Attach(mux, ti.service, nil) - - body := []byte(`{"client_name":"adapter smoke test","redirect_uris":["http://localhost:3000/callback"],"token_endpoint_auth_method":"none"}`) - req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/x/mcp/"+slug+"/register", bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - require.Equal(t, http.StatusCreated, w.Code, "register adapter should return 201; body=%s", w.Body.String()) - var resp struct { - ClientID string `json:"client_id"` - } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - require.NotEmpty(t, resp.ClientID, "register response must include a minted client_id") -} - -// TestAttach_OAuthAuthorizeRoute_RedirectsToConsent drives the -// chi.Attach-wired GET /x/mcp/{slug}/authorize and verifies the -// authorize adapter dispatches to mcp.Service.ServeAuthorize correctly: -// a valid request on a public issuer-gated endpoint should 302 to the -// consent URL (/x/mcp/{slug}/connect). Catches a route-wiring regression -// where the wrong adapter or wrong chi URL param name would be observed -// as a 500 or 404 here. -func TestAttach_OAuthAuthorizeRoute_RedirectsToConsent(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, issuerID := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public") - - // Pre-register a client so authorize finds it. - clientID := "test-client-" + uuid.NewString() - redirectURI := "http://localhost:3000/callback" - _, err := usersessionsrepo.New(ti.conn).CreateUserSessionClient(ctx, usersessionsrepo.CreateUserSessionClientParams{ - UserSessionIssuerID: issuerID, - ClientID: clientID, - ClientName: "authorize-smoke", - RedirectUris: []string{redirectURI}, - TokenEndpointAuthMethod: "none", - }) - require.NoError(t, err) - - verifier := "verifier-" + uuid.NewString() - sum := sha256.Sum256([]byte(verifier)) - codeChallenge := base64.RawURLEncoding.EncodeToString(sum[:]) - - q := url.Values{} - q.Set("response_type", "code") - q.Set("client_id", clientID) - q.Set("redirect_uri", redirectURI) - q.Set("code_challenge", codeChallenge) - q.Set("code_challenge_method", "S256") - q.Set("state", "authorize-smoke-state") - - mux := goahttp.NewMuxer() - xmcp.Attach(mux, ti.service, nil) - - req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/x/mcp/"+slug+"/authorize?"+q.Encode(), nil) - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - require.Equal(t, http.StatusFound, w.Code, "authorize adapter must 302 to consent; body=%s", w.Body.String()) - loc := w.Header().Get("Location") - require.Contains(t, loc, "/x/mcp/"+slug+"/connect", "302 must point at the /x/mcp consent URL") -} - -// TestAttach_OAuthConsentRoute_RendersForm drives the chi.Attach-wired -// GET /x/mcp/{slug}/connect against a pre-stamped AuthnChallengeState -// and verifies the consent adapter dispatches to ServeConsent (renders -// 200 with the consent template HTML). Catches route-wiring regressions -// distinct from the authorize/token paths. -func TestAttach_OAuthConsentRoute_RendersForm(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, issuerID := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public") - - clientID := "test-client-" + uuid.NewString() - redirectURI := "http://localhost:3000/callback" - _, err := usersessionsrepo.New(ti.conn).CreateUserSessionClient(ctx, usersessionsrepo.CreateUserSessionClientParams{ - UserSessionIssuerID: issuerID, - ClientID: clientID, - ClientName: "consent-smoke", - RedirectUris: []string{redirectURI}, - TokenEndpointAuthMethod: "none", - }) - require.NoError(t, err) - - // Stamp an AuthnChallengeState the way HandleAuthorize would have. - challengeID := uuid.NewString() - anonymous := urn.NewAnonymousSubject(uuid.NewString()) - authnCache := cache.NewTypedObjectCache[mcp.AuthnChallengeState](ti.logger, ti.cacheAdapter, cache.SuffixNone) - require.NoError(t, authnCache.Store(ctx, mcp.AuthnChallengeState{ - ID: challengeID, - UserSessionIssuerID: issuerID, - Endpoint: mcp.EndpointRef{ - McpSlug: slug, - CustomDomainID: uuid.NullUUID{}, - McpServerID: uuid.NullUUID{}, // legacy /mcp shape — ServeConsent re-resolves via mcpSlug - RouteBase: "x/mcp", - }, - ClientID: clientID, - RedirectURI: redirectURI, - CodeChallenge: "abc", - CodeChallengeMethod: "S256", - CSRFToken: "csrf-token", - Subject: &anonymous, - CreatedAt: time.Now(), - })) - - mux := goahttp.NewMuxer() - xmcp.Attach(mux, ti.service, nil) - - req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/x/mcp/"+slug+"/connect?state="+challengeID, nil) - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code, "consent adapter must render 200; body=%s", w.Body.String()) - require.Contains(t, w.Header().Get("Content-Type"), "text/html") -} - -// TestAttach_OAuthTokenRoute_MintsAccessToken drives the chi.Attach-wired -// POST /x/mcp/{slug}/token with a pre-seeded UserSessionGrant and verifies -// the token adapter dispatches to ServeToken (mints a Bearer access -// token). Catches route-wiring regressions distinct from the upstream -// adapter paths. -func TestAttach_OAuthTokenRoute_MintsAccessToken(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, issuerID := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public") - - clientID := "test-client-" + uuid.NewString() - redirectURI := "http://localhost:3000/callback" - _, err := usersessionsrepo.New(ti.conn).CreateUserSessionClient(ctx, usersessionsrepo.CreateUserSessionClientParams{ - UserSessionIssuerID: issuerID, - ClientID: clientID, - ClientName: "token-smoke", - RedirectUris: []string{redirectURI}, - TokenEndpointAuthMethod: "none", - }) - require.NoError(t, err) - - verifier := "verifier-" + uuid.NewString() - sum := sha256.Sum256([]byte(verifier)) - codeChallenge := base64.RawURLEncoding.EncodeToString(sum[:]) - code := "auth-code-" + uuid.NewString() - subject := urn.NewAnonymousSubject(uuid.NewString()) - grantCache := cache.NewTypedObjectCache[mcp.UserSessionGrant](ti.logger, ti.cacheAdapter, cache.SuffixNone) - require.NoError(t, grantCache.Store(ctx, mcp.UserSessionGrant{ - Code: code, - UserSessionIssuerID: issuerID, - ClientID: clientID, - RedirectURI: redirectURI, - CodeChallenge: codeChallenge, - CodeChallengeMethod: "S256", - Subject: subject, - CreatedAt: time.Now(), - })) - - form := url.Values{} - form.Set("grant_type", "authorization_code") - form.Set("code", code) - form.Set("redirect_uri", redirectURI) - form.Set("client_id", clientID) - form.Set("code_verifier", verifier) - - mux := goahttp.NewMuxer() - xmcp.Attach(mux, ti.service, nil) - - req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/x/mcp/"+slug+"/token", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code, "token adapter must mint a Bearer; body=%s", w.Body.String()) - var resp struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - } - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp)) - require.NotEmpty(t, resp.AccessToken) - require.Equal(t, "Bearer", resp.TokenType) -} - -// TestAttach_OAuthRevokeRoute_HandlesUnknownTokenAsSuccess drives the -// chi.Attach-wired POST /x/mcp/{slug}/revoke against a non-existent -// token and verifies the revoke adapter dispatches to ServeRevoke -// (which per RFC 7009 §2.2 must return 200 for unknown tokens, not 4xx). -// Catches a route-wiring regression that would surface as the wrong -// adapter being invoked. -func TestAttach_OAuthRevokeRoute_HandlesUnknownTokenAsSuccess(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, issuerID := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public") - - // Revoke requires client authentication (RFC 7009 §2.1); seed a - // public client (token_endpoint_auth_method=none). - clientID := "test-client-" + uuid.NewString() - _, err := usersessionsrepo.New(ti.conn).CreateUserSessionClient(ctx, usersessionsrepo.CreateUserSessionClientParams{ - UserSessionIssuerID: issuerID, - ClientID: clientID, - ClientName: "revoke-smoke", - RedirectUris: []string{"http://localhost:3000/callback"}, - TokenEndpointAuthMethod: "none", - }) - require.NoError(t, err) - - mux := goahttp.NewMuxer() - xmcp.Attach(mux, ti.service, nil) - - form := url.Values{} - form.Set("token", "definitely-not-a-real-token") - form.Set("token_type_hint", "access_token") - form.Set("client_id", clientID) - req := httptest.NewRequestWithContext(ctx, http.MethodPost, "/x/mcp/"+slug+"/revoke", strings.NewReader(form.Encode())) - req.Header.Set("Content-Type", "application/x-www-form-urlencoded") - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - - require.Equal(t, http.StatusOK, w.Code, "revoke adapter must return 200 for unknown tokens (RFC 7009 §2.2); body=%s", w.Body.String()) -} - -// TestRequireUserSessionIssuer_DanglingFKReturnsNotFound asserts the -// behaviour of mcp.Service.RequireUserSessionIssuer when the -// user_session_issuer FK target has been deleted out from under an -// in-memory ResolvedMcpEndpoint snapshot — the race window between -// loading mcp_servers and using the issuer_id. The schema's -// ON DELETE SET NULL on mcp_servers.user_session_issuer_id only -// triggers on the next write; in-flight requests still hold the old -// UUID. The defensive lookup must surface CodeNotFound so the request -// can fail closed. -func TestRequireUserSessionIssuer_DanglingFKReturnsNotFound(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, mcpServer, issuerID := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public") - mcpEndpoint, err := mcpendpointsrepo.New(ti.conn).GetMCPEndpointByCustomDomainAndSlug(ctx, mcpendpointsrepo.GetMCPEndpointByCustomDomainAndSlugParams{ - Slug: slug, - CustomDomainID: uuid.NullUUID{}, - }) - require.NoError(t, err) - project, err := projectsrepo.New(ti.conn).GetProjectByID(ctx, *authCtx.ProjectID) - require.NoError(t, err) - endpoint := mcp.NewResolvedMcpEndpointFromMcpServer(&mcpEndpoint, &mcpServer, project.OrganizationID, "x/mcp") - - // Sanity check: the issuer FK resolves cleanly before deletion. - require.NoError(t, ti.mcpService.RequireUserSessionIssuer(ctx, endpoint)) - - // Soft-delete the issuer. GetUserSessionIssuerByID filters on - // `deleted IS FALSE`, so the next call must miss. - err = testrepo.New(ti.conn).ForceSoftDeleteUserSessionIssuer(ctx, testrepo.ForceSoftDeleteUserSessionIssuerParams{ - ID: issuerID, - ProjectID: *authCtx.ProjectID, - }) - require.NoError(t, err) - - err = ti.mcpService.RequireUserSessionIssuer(ctx, endpoint) - require.Error(t, err, "dangling issuer FK must surface as a request-level error, not be silently ignored") - require.Contains(t, err.Error(), "user_session_issuer not found", "error message should identify the dangling FK target") -} - -// TestHandleIDPCallback_McpServerMismatch_Returns guard verifies the -// state-confusion check inside loadResolvedMcpEndpointByRef: if the -// cached EndpointRef.McpServerID no longer matches the mcp_server the -// addressed mcp_endpoint currently resolves to, the resumption is -// rejected. Triggered by an mcp_endpoint being re-pointed at a -// different mcp_server mid-flow. -func TestHandleIDPCallback_McpServerMismatchRejected(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, issuerID := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public") - - // Cache a challenge state whose McpServerID points at a different - // mcp_server than the live mcp_endpoint resolves to. The simplest - // way to produce a different valid-shape UUID is to mint one - // uncorrelated with the endpoint — the guard compares by UUID - // equality, not row existence. - staleServerID := uuid.New() - - authnCache := cache.NewTypedObjectCache[mcp.AuthnChallengeState](ti.logger, ti.cacheAdapter, cache.SuffixNone) - challengeID := uuid.NewString() - require.NoError(t, authnCache.Store(ctx, mcp.AuthnChallengeState{ - ID: challengeID, - UserSessionIssuerID: issuerID, - Endpoint: mcp.EndpointRef{ - McpSlug: slug, - CustomDomainID: uuid.NullUUID{}, - McpServerID: uuid.NullUUID{UUID: staleServerID, Valid: true}, - RouteBase: "x/mcp", - }, - ClientID: "test-client", - RedirectURI: "http://localhost:3000/callback", - CodeChallenge: "abc", - CodeChallengeMethod: "S256", - CSRFToken: "csrf-token", - CreatedAt: time.Now(), - })) - - q := url.Values{ - "state": {challengeID}, - "code": {"idp-auth-code-mismatch"}, - } - req := httptest.NewRequestWithContext(ctx, http.MethodGet, "/x/mcp/idp_callback?"+q.Encode(), nil) - req = req.WithContext(context.WithValue(ctx, chi.RouteCtxKey, chi.NewRouteContext())) - - w := httptest.NewRecorder() - err := ti.mcpService.HandleIDPCallback(w, req) - require.Error(t, err, "callback against a mismatching mcp_server ref must fail") - require.Contains(t, err.Error(), "does not match", "guard error message should describe the mismatch") -} - -// TestServeMCP_PublicRemoteBackend_GetForwardsToUpstream exercises the GET -// leg of the MCP Streamable HTTP transport (spec §3.3 "Listening for -// Messages from the Server"). The chi mux registers GET / DELETE / POST -// on /x/mcp/{slug} and [Service.buildProxy] wires up the proxy.Get -// branch; this test confirms the GET method makes it through ServeMCP -// unchanged and the proxy forwards to upstream. Catches a regression -// where GET would be dropped at any layer between Attach and the proxy. -func TestServeMCP_PublicRemoteBackend_GetForwardsToUpstream(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - var gotMethod, gotSession string - done := make(chan struct{}, 1) - upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - gotMethod = r.Method - gotSession = r.Header.Get("Mcp-Session-Id") - w.Header().Set("Content-Type", "text/event-stream") - w.WriteHeader(http.StatusOK) - _, _ = w.Write([]byte("data: {}\n\n")) - done <- struct{}{} - })) - t.Cleanup(upstream.Close) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, mcpServer, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, upstream.URL, "public") - token := mintAccessTokenForSeededEndpoint(t, ctx, ti, slug, mcpServer) - - rr := runHandlerWithHeaders(t, ctx, ti, http.MethodGet, slug, bearer(token), nil, map[string]string{"Mcp-Session-Id": "sse-session-1"}) - select { - case <-done: - case <-time.After(5 * time.Second): - t.Fatalf("upstream not invoked within 5s; status=%d", rr.Code) - } - require.Equal(t, http.StatusOK, rr.Code, "GET must proxy through; body=%s", rr.Body.String()) - require.Equal(t, http.MethodGet, gotMethod) - require.Equal(t, "sse-session-1", gotSession, "Mcp-Session-Id must be forwarded to upstream on GET") -} - -// TestServeMCP_PublicRemoteBackend_ToolsCallForwardsToUpstream exercises -// the full request → proxy → interceptor pipeline for a tools/call. -// Previous tests only fire initialize through the proxy; this one -// catches regressions in the per-tool interceptor chain wired up by -// [Service.buildProxy] (counter, usage limits, toolset-id strip, usage -// tracking response) for public visibility. -func TestServeMCP_PublicRemoteBackend_ToolsCallForwardsToUpstream(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - - mockServer := testmcp.NewStreamableHTTPServer(t, &testmcp.Server{ - Tools: []testmcp.Tool{{ - Name: "echo", - Description: "Echo the provided message", - InputSchema: map[string]any{ - "type": "object", - "properties": map[string]any{ - "message": map[string]any{"type": "string"}, - }, - "required": []any{"message"}, - }, - Response: testmcp.ToolResponse{ - Content: []map[string]any{{"type": "text", "text": "echoed"}}, - }, - }}, - }) - t.Cleanup(mockServer.Close) - - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, mcpServer, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, mockServer.URL, "public") - token := mintAccessTokenForSeededEndpoint(t, ctx, ti, slug, mcpServer) - - // Initialize first to get a session id, then drive a tools/call on - // that session so the proxy's per-tool interceptor pipeline gets to - // run. - initResp := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(token), []byte(initializeBody)) - require.Equal(t, http.StatusOK, initResp.Code, "initialize body=%s", initResp.Body.String()) - sessionID := initResp.Header().Get("Mcp-Session-Id") - require.NotEmpty(t, sessionID) - - toolsCallBody := []byte(`{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"echo","arguments":{"message":"hello"}}}`) - rr := runHandlerWithHeaders(t, ctx, ti, http.MethodPost, slug, bearer(token), toolsCallBody, map[string]string{"Mcp-Session-Id": sessionID}) - require.Equal(t, http.StatusOK, rr.Code, "tools/call body=%s", rr.Body.String()) - require.Contains(t, rr.Body.String(), "echoed", "upstream tool response must be relayed back") -} - -// TestServeMCP_CustomDomainMismatchReturns404 covers the resolution -// scoping in [mcp.Service.ResolveMCPEndpointAndServer]: an mcp_endpoint -// registered against a custom domain must not resolve for a request -// arriving without that domain's context, even when the slug matches. -// Catches a regression in the `custom_domain_id IS NOT DISTINCT FROM $2` -// predicate or any future refactor that drops the domain scoping. -// -// The schema's UNIQUE (organization_id) on custom_domains prevents the -// "two domains in one org" framing — testing platform-context vs -// domain-context is equivalent (each scope must not bleed into the -// other) and uses the same predicate. -func TestServeMCP_CustomDomainMismatchReturns404(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - mockServer := testmcp.NewStreamableHTTPServer(t, &testmcp.Server{Tools: nil}) - t.Cleanup(mockServer.Close) - - domain := seedCustomDomain(t, ctx, ti, authCtx.ActiveOrganizationID, "xmcp-cd-mismatch-"+uuid.NewString()[:8]+".example.com") - - toolsetSlug := "tsl-" + uuid.NewString() - toolset, err := toolsetsrepo.New(ti.conn).CreateToolset(ctx, toolsetsrepo.CreateToolsetParams{ - OrganizationID: authCtx.ActiveOrganizationID, - ProjectID: *authCtx.ProjectID, - Name: "cd-mismatch-test", - Slug: toolsetSlug, - Description: pgtype.Text{String: "custom domain mismatch test", Valid: true}, - DefaultEnvironmentSlug: pgtype.Text{String: "", Valid: false}, - McpSlug: pgtype.Text{String: toolsetSlug, Valid: true}, - McpEnabled: true, - }) - require.NoError(t, err) - - slug, _ := seedToolsetMCPEndpointOnDomain(t, ctx, ti, *authCtx.ProjectID, toolset, "public", uuid.NullUUID{UUID: domain.ID, Valid: true}) - - // Request arriving without a custom-domain context must miss the - // domain-scoped endpoint. - rr := runHandler(t, ctx, ti, http.MethodPost, slug, "", []byte(initializeBody)) - require.Equal(t, http.StatusNotFound, rr.Code, "endpoint scoped to a custom domain must not resolve on the platform domain") -} - -// TestServeMCP_IssuerGatedToolsetBackend_PrivateHappyPath is the -// private-visibility companion of -// [TestServeMCP_IssuerGatedToolsetBackend_HappyPath]. Without the -// `skipIssuerGate` + visibility-skip fix on the toolset branch, a -// private toolset-backed issuer-gated endpoint would be rejected by -// the legacy identity-auth chain inside ServeToolsetResolved even -// though ApplyIssuerGate already validated the JWT. -func TestServeMCP_IssuerGatedToolsetBackend_PrivateHappyPath(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, mcpServer, issuerID := seedIssuerGatedToolsetMCPEndpoint(t, ctx, ti, authCtx.ActiveOrganizationID, *authCtx.ProjectID, "private") - mcpEndpoint, err := mcpendpointsrepo.New(ti.conn).GetMCPEndpointByCustomDomainAndSlug(ctx, mcpendpointsrepo.GetMCPEndpointByCustomDomainAndSlugParams{ - Slug: slug, - CustomDomainID: uuid.NullUUID{}, - }) - require.NoError(t, err) - project, err := projectsrepo.New(ti.conn).GetProjectByID(ctx, *authCtx.ProjectID) - require.NoError(t, err) - endpoint := mcp.NewResolvedMcpEndpointFromMcpServer(&mcpEndpoint, &mcpServer, project.OrganizationID, "x/mcp") - - // Private endpoints route through the IDP and stamp user subjects. - subject := urn.NewUserSubject("user_" + uuid.NewString()[:8]) - accessToken := mintIssuerGatedAccessToken(t, ctx, ti, slug, endpoint, issuerID, subject) - - rr := runHandler(t, ctx, ti, http.MethodPost, slug, bearer(accessToken), []byte(initializeBody)) - require.NotEqual(t, http.StatusUnauthorized, rr.Code, "issuer-gated bearer on a private toolset must not be re-rejected; body=%s", rr.Body.String()) - require.Equal(t, http.StatusOK, rr.Code, "ServeMCP should respond 200; body=%s", rr.Body.String()) -} diff --git a/server/internal/xmcp/service.go b/server/internal/xmcp/service.go deleted file mode 100644 index b9b5e3ffe54..00000000000 --- a/server/internal/xmcp/service.go +++ /dev/null @@ -1,237 +0,0 @@ -// Package xmcp implements the experimental MCP runtime endpoint at -// /x/mcp/{slug}. It is a temporary path that proves out the MCP Servers -// / MCP Endpoints fronting model — slug + optional custom domain → -// mcp_endpoint → mcp_server → backend dispatch (Remote MCP proxy vs. -// existing toolset-backed serving). The unified runtime dispatch logic -// lives on mcp.Service; this package now exists primarily to mount the -// /x/mcp routes and the OAuth/.well-known adapters that resolve slugs -// to ResolvedMcpEndpoints. Once /mcp absorbs the model fully (AGE-1902), -// /x/mcp can be removed. -package xmcp - -import ( - "fmt" - "log/slog" - "net/http" - - "github.com/go-chi/chi/v5" - "github.com/jackc/pgx/v5/pgxpool" - goahttp "goa.design/goa/v3/http" - - "github.com/speakeasy-api/gram/server/internal/attr" - "github.com/speakeasy-api/gram/server/internal/encryption" - "github.com/speakeasy-api/gram/server/internal/mcp" - "github.com/speakeasy-api/gram/server/internal/mcpmetadata" - "github.com/speakeasy-api/gram/server/internal/o11y" - "github.com/speakeasy-api/gram/server/internal/oauth/wellknown" - "github.com/speakeasy-api/gram/server/internal/oops" -) - -// RuntimePath is the experimental runtime path served by this package. -const RuntimePath = "/x/mcp/{slug}" - -// Service owns dependencies for the experimental MCP runtime endpoint. -// The runtime dispatch (resolve mcp_endpoint, run issuer gate, dispatch -// to remote/toolset backend) lives on mcp.Service; this struct holds -// only the state required for the OAuth route adapters and the -// per-backend .well-known responders mounted under /x/mcp. -type Service struct { - logger *slog.Logger - db *pgxpool.Pool - enc *encryption.Client - mcpService *mcp.Service -} - -// NewService constructs a Service with its full dependency graph wired up. -func NewService( - logger *slog.Logger, - db *pgxpool.Pool, - enc *encryption.Client, - mcpService *mcp.Service, -) *Service { - logger = logger.With(attr.SlogComponent("xmcp")) - return &Service{ - logger: logger, - db: db, - enc: enc, - mcpService: mcpService, - } -} - -// Attach registers the experimental MCP runtime handler for all supported -// HTTP methods. DELETE, GET, and POST are required by the MCP Streamable -// HTTP transport (see spec § Session Management for DELETE and § Listening -// for Messages from the Server for GET). -// -// Attach also registers /x/mcp aliases for the install page and OAuth -// .well-known metadata routes. The install page delegates to mcpmetadata -// for parity with /mcp; the .well-known routes are owned by xmcp directly -// so they can dispatch per-backend (see [Service.HandleWellKnownOAuthServerMetadata]). -func Attach(mux goahttp.Muxer, service *Service, metadataService *mcpmetadata.Service) { - handler := oops.MCPErrHandle(service.logger, service.ServeMCP).ServeHTTP - o11y.AttachHandler(mux, http.MethodDelete, RuntimePath, handler) - o11y.AttachHandler(mux, http.MethodGet, RuntimePath, handler) - o11y.AttachHandler(mux, http.MethodPost, RuntimePath, handler) - - o11y.AttachHandler(mux, http.MethodGet, "/x/mcp/{mcpSlug}/install", oops.ErrHandle(service.logger, metadataService.ServeInstallPage).ServeHTTP) - // The install page script URL is hardcoded to /mcp/install-page-{hash}.js - // inside the rendered install page HTML (see mcpmetadata/impl.go), so the - // /mcp variant registered by mcp.Attach is what the served HTML actually - // loads. This is duplicated here (but commented out to prevent errors) to - // ensure its not missed during future migration of the runtime endpoint - // from /x/mcp to /mcp. - // o11y.AttachHandler(mux, http.MethodGet, "/mcp/install-page-{hash}.js", oops.ErrHandle(service.logger, metadataService.ServeInstallPageScript).ServeHTTP) - - o11y.AttachHandler(mux, http.MethodGet, wellknown.OAuthAuthorizationServerPath+"/x/mcp/{mcpSlug}", oops.ErrHandle(service.logger, service.HandleWellKnownOAuthServerMetadata).ServeHTTP) - o11y.AttachHandler(mux, http.MethodGet, wellknown.OAuthProtectedResourcePath+"/x/mcp/{mcpSlug}", oops.ErrHandle(service.logger, service.HandleWellKnownOAuthProtectedResourceMetadata).ServeHTTP) - - // Issuer-gated OAuth handler family. Each route resolves the slug to - // an /x/mcp-keyed *mcp.ResolvedMcpEndpoint via - // [mcp.Service.LoadResolvedMcpEndpointBySlug] and delegates to the - // matching mcp.Service.Serve* post-resolution handler. - // - // idp_callback and remote_login_callback are mounted only at the - // slug-less global URLs: the authorize and consent handlers build - // their redirect_uris via endpoint.IDPCallbackURL and - // ChallengeManager.callbackURL, both of which always emit - // `//...` without the slug. The handlers recover - // the originating slug from the cached challenge / login state. /mcp - // also keeps per-slug variants for back-compat with pre-global-URL - // clients; /x/mcp is a fresh surface so the dead routes aren't mounted. - o11y.AttachHandler(mux, http.MethodPost, "/x/mcp/{mcpSlug}/register", oops.ErrHandle(service.logger, service.handleOAuthRegister).ServeHTTP) - o11y.AttachHandler(mux, http.MethodGet, "/x/mcp/{mcpSlug}/authorize", oops.ErrHandle(service.logger, service.handleOAuthAuthorize).ServeHTTP) - o11y.AttachHandler(mux, http.MethodGet, "/x/mcp/{mcpSlug}/connect", oops.ErrHandle(service.logger, service.handleOAuthConsent).ServeHTTP) - o11y.AttachHandler(mux, http.MethodPost, "/x/mcp/{mcpSlug}/connect", oops.ErrHandle(service.logger, service.handleOAuthConsent).ServeHTTP) - o11y.AttachHandler(mux, http.MethodPost, "/x/mcp/{mcpSlug}/connect/remote-session", oops.ErrHandle(service.logger, service.handleOAuthConsentAction).ServeHTTP) - o11y.AttachHandler(mux, http.MethodPost, "/x/mcp/{mcpSlug}/connect/mcp", oops.ErrHandle(service.logger, service.handleOAuthConsentMCP).ServeHTTP) - o11y.AttachHandler(mux, http.MethodDelete, "/x/mcp/{mcpSlug}/connect/mcp", oops.ErrHandle(service.logger, service.handleOAuthConsentMCP).ServeHTTP) - o11y.AttachHandler(mux, http.MethodGet, "/x/mcp/{mcpSlug}/connect/first-party", oops.ErrHandle(service.logger, service.handleFirstPartyConnect).ServeHTTP) - o11y.AttachHandler(mux, http.MethodPost, "/x/mcp/{mcpSlug}/token", oops.ErrHandle(service.logger, service.handleOAuthToken).ServeHTTP) - o11y.AttachHandler(mux, http.MethodPost, "/x/mcp/{mcpSlug}/revoke", oops.ErrHandle(service.logger, service.handleOAuthRevoke).ServeHTTP) - o11y.AttachHandler(mux, http.MethodGet, "/x/mcp/idp_callback", oops.ErrHandle(service.logger, service.mcpService.HandleIDPCallback).ServeHTTP) - o11y.AttachHandler(mux, http.MethodGet, "/x/mcp/remote_login_callback", oops.ErrHandle(service.logger, service.mcpService.HandleRemoteLoginCallback).ServeHTTP) -} - -// handleOAuthRegister adapts the chi /x/mcp/{mcpSlug}/register route to -// mcp.Service.ServeRegister by resolving the slug to an ResolvedMcpEndpoint. -func (s *Service) handleOAuthRegister(w http.ResponseWriter, r *http.Request) error { - endpoint, err := s.resolveOAuthEndpoint(r) - if err != nil { - return err - } - if err := s.mcpService.ServeRegister(w, r, endpoint); err != nil { - return fmt.Errorf("serve oauth register: %w", err) - } - return nil -} - -// handleOAuthAuthorize adapts the chi /x/mcp/{mcpSlug}/authorize route -// to mcp.Service.ServeAuthorize by resolving the slug to an -// ResolvedMcpEndpoint. -func (s *Service) handleOAuthAuthorize(w http.ResponseWriter, r *http.Request) error { - endpoint, err := s.resolveOAuthEndpoint(r) - if err != nil { - return err - } - if err := s.mcpService.ServeAuthorize(w, r, endpoint); err != nil { - return fmt.Errorf("serve oauth authorize: %w", err) - } - return nil -} - -// handleOAuthConsent adapts the chi /x/mcp/{mcpSlug}/connect (GET/POST) -// route to mcp.Service.ServeConsent. -func (s *Service) handleOAuthConsent(w http.ResponseWriter, r *http.Request) error { - endpoint, err := s.resolveOAuthEndpoint(r) - if err != nil { - return err - } - if err := s.mcpService.ServeConsent(w, r, endpoint); err != nil { - return fmt.Errorf("serve oauth consent: %w", err) - } - return nil -} - -// handleOAuthConsentAction adapts the chi -// /x/mcp/{mcpSlug}/connect/remote-session route to -// mcp.Service.ServeConsentAction. -func (s *Service) handleOAuthConsentAction(w http.ResponseWriter, r *http.Request) error { - endpoint, err := s.resolveOAuthEndpoint(r) - if err != nil { - return err - } - if err := s.mcpService.ServeConsentAction(w, r, endpoint); err != nil { - return fmt.Errorf("serve oauth consent action: %w", err) - } - return nil -} - -// handleOAuthConsentMCP adapts the chi /x/mcp/{mcpSlug}/connect/mcp -// route to mcp.Service.ServeConsentMCP. -func (s *Service) handleOAuthConsentMCP(w http.ResponseWriter, r *http.Request) error { - endpoint, err := s.resolveOAuthEndpoint(r) - if err != nil { - return err - } - if err := s.mcpService.ServeConsentMCP(w, r, endpoint); err != nil { - return fmt.Errorf("serve oauth consent mcp: %w", err) - } - return nil -} - -// handleFirstPartyConnect adapts the chi /x/mcp/{mcpSlug}/connect/first-party -// route to mcp.Service.ServeFirstPartyConnect — the dashboard's first-party -// entry point for linking an issuer-gated server's upstream sessions. -func (s *Service) handleFirstPartyConnect(w http.ResponseWriter, r *http.Request) error { - endpoint, err := s.resolveOAuthEndpoint(r) - if err != nil { - return err - } - if err := s.mcpService.ServeFirstPartyConnect(w, r, endpoint); err != nil { - return fmt.Errorf("serve first-party connect: %w", err) - } - return nil -} - -// handleOAuthToken adapts the chi /x/mcp/{mcpSlug}/token route to -// mcp.Service.ServeToken. -func (s *Service) handleOAuthToken(w http.ResponseWriter, r *http.Request) error { - endpoint, err := s.resolveOAuthEndpoint(r) - if err != nil { - return err - } - if err := s.mcpService.ServeToken(w, r, endpoint); err != nil { - return fmt.Errorf("serve oauth token: %w", err) - } - return nil -} - -// handleOAuthRevoke adapts the chi /x/mcp/{mcpSlug}/revoke route to -// mcp.Service.ServeRevoke. -func (s *Service) handleOAuthRevoke(w http.ResponseWriter, r *http.Request) error { - endpoint, err := s.resolveOAuthEndpoint(r) - if err != nil { - return err - } - if err := s.mcpService.ServeRevoke(w, r, endpoint); err != nil { - return fmt.Errorf("serve oauth revoke: %w", err) - } - return nil -} - -// resolveOAuthEndpoint reads the mcpSlug chi param and resolves it to -// an issuer-gated *mcp.ResolvedMcpEndpoint via the /x/mcp mcp_endpoints → -// mcp_servers path. Returned to the per-handler adapters above. -func (s *Service) resolveOAuthEndpoint(r *http.Request) (*mcp.ResolvedMcpEndpoint, error) { - ctx := r.Context() - slug := chi.URLParam(r, "mcpSlug") - if slug == "" { - return nil, oops.E(oops.CodeBadRequest, nil, "an mcp slug must be provided") - } - logger := s.logger.With(attr.SlogToolsetMCPSlug(slug)) - endpoint, err := s.mcpService.LoadResolvedMcpEndpointBySlug(ctx, logger, slug, "x/mcp") - if err != nil { - return nil, fmt.Errorf("load resolved mcp endpoint: %w", err) - } - return endpoint, nil -} diff --git a/server/internal/xmcp/setup_test.go b/server/internal/xmcp/setup_test.go deleted file mode 100644 index 3c5dbc28e9c..00000000000 --- a/server/internal/xmcp/setup_test.go +++ /dev/null @@ -1,539 +0,0 @@ -package xmcp_test - -import ( - "bytes" - "context" - "crypto/rand" - "encoding/hex" - "fmt" - "log" - "log/slog" - "net/http/httptest" - "net/url" - "os" - "testing" - "time" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" - "github.com/jackc/pgx/v5/pgxpool" - "github.com/stretchr/testify/require" - "go.opentelemetry.io/otel/trace" - - "github.com/speakeasy-api/gram/server/internal/audit" - "github.com/speakeasy-api/gram/server/internal/auth" - "github.com/speakeasy-api/gram/server/internal/auth/assistanttokens" - "github.com/speakeasy-api/gram/server/internal/auth/chatsessions" - "github.com/speakeasy-api/gram/server/internal/auth/sessions" - "github.com/speakeasy-api/gram/server/internal/authz" - "github.com/speakeasy-api/gram/server/internal/authztest" - "github.com/speakeasy-api/gram/server/internal/billing" - "github.com/speakeasy-api/gram/server/internal/cache" - "github.com/speakeasy-api/gram/server/internal/conv" - customdomainsrepo "github.com/speakeasy-api/gram/server/internal/customdomains/repo" - "github.com/speakeasy-api/gram/server/internal/encryption" - "github.com/speakeasy-api/gram/server/internal/environments" - "github.com/speakeasy-api/gram/server/internal/feature" - "github.com/speakeasy-api/gram/server/internal/functions" - "github.com/speakeasy-api/gram/server/internal/guardian" - keysrepo "github.com/speakeasy-api/gram/server/internal/keys/repo" - "github.com/speakeasy-api/gram/server/internal/killswitches/mcptoolexecution" - "github.com/speakeasy-api/gram/server/internal/mcp" - "github.com/speakeasy-api/gram/server/internal/mcp/toolfilter" - mcpendpointsrepo "github.com/speakeasy-api/gram/server/internal/mcpendpoints/repo" - mcpmetadatarepo "github.com/speakeasy-api/gram/server/internal/mcpmetadata/repo" - "github.com/speakeasy-api/gram/server/internal/mcpservers" - mcpserversrepo "github.com/speakeasy-api/gram/server/internal/mcpservers/repo" - "github.com/speakeasy-api/gram/server/internal/oops" - "github.com/speakeasy-api/gram/server/internal/rag" - "github.com/speakeasy-api/gram/server/internal/ratelimit" - "github.com/speakeasy-api/gram/server/internal/remotemcp" - "github.com/speakeasy-api/gram/server/internal/remotemcp/remotemcptest" - remotemcprepo "github.com/speakeasy-api/gram/server/internal/remotemcp/repo" - "github.com/speakeasy-api/gram/server/internal/remotesessions" - "github.com/speakeasy-api/gram/server/internal/shadowmcp" - "github.com/speakeasy-api/gram/server/internal/telemetry" - "github.com/speakeasy-api/gram/server/internal/testenv" - "github.com/speakeasy-api/gram/server/internal/thirdparty/openrouter" - "github.com/speakeasy-api/gram/server/internal/thirdparty/posthog" - "github.com/speakeasy-api/gram/server/internal/thirdparty/workos" - "github.com/speakeasy-api/gram/server/internal/toolcallobserver" - toolsetsrepo "github.com/speakeasy-api/gram/server/internal/toolsets/repo" - "github.com/speakeasy-api/gram/server/internal/usersessions" - usersessionsrepo "github.com/speakeasy-api/gram/server/internal/usersessions/repo" - "github.com/speakeasy-api/gram/server/internal/xmcp" - "github.com/speakeasy-api/gram/tunnel/route" -) - -var ( - infra *testenv.Environment - funcs functions.ToolCaller -) - -func TestMain(m *testing.M) { - res, cleanup, err := testenv.Launch(context.Background(), testenv.LaunchOptions{Postgres: true, Redis: true, ClickHouse: true, Temporal: true}) - if err != nil { - log.Fatalf("launch test infrastructure: %v", err) - os.Exit(1) - } - - infra = res - - code := m.Run() - - if err := cleanup(); err != nil { - log.Fatalf("cleanup test infrastructure: %v", err) - os.Exit(1) - } - - os.Exit(code) -} - -type testInstance struct { - service *xmcp.Service - mcpService *mcp.Service - conn *pgxpool.Pool - sessionManager *sessions.Manager - tracerProvider trace.TracerProvider - logger *slog.Logger - enc *encryption.Client - authzEngine *authz.Engine - shadowMCPClient *shadowmcp.Client - cacheAdapter cache.Cache - serverURL *url.URL -} - -func newTestService(t *testing.T) (context.Context, *testInstance) { - t.Helper() - - ctx := t.Context() - - logger := testenv.NewLogger(t) - tracerProvider := testenv.NewTracerProvider(t) - meterProvider := testenv.NewMeterProvider(t) - guardianPolicy, err := guardian.NewUnsafePolicy(tracerProvider, []string{}) - require.NoError(t, err) - - conn, err := infra.CloneTestDatabase(t, "xmcptest") - require.NoError(t, err) - - redisClient, err := infra.NewRedisClient(t, 0) - require.NoError(t, err) - - billingClient := billing.NewStubClient(logger, tracerProvider) - sessionManager := testenv.NewTestManager(t, logger, tracerProvider, conn, redisClient, cache.Suffix("gram-xmcp-test"), billingClient) - - ctx = authztest.InitAuthContext(t, ctx, conn, sessionManager) - - serverURL, err := url.Parse("http://0.0.0.0") - require.NoError(t, err) - - enc := testenv.NewEncryptionClient(t) - chConn, err := infra.NewClickhouseClient(t) - require.NoError(t, err) - authzEngine := authz.NewEngine(logger, conn, authztest.ChallengeLoggingAlwaysDisabled, workos.NewStubClient()) - - mcpMetadataRepo := mcpmetadatarepo.New(conn) - env := environments.NewEnvironmentEntries(logger, conn, enc, mcpMetadataRepo) - posthogClient := posthog.New(ctx, logger, "test-posthog-key", "test-posthog-host", "") - cacheAdapter := cache.NewRedisCacheAdapter(redisClient) - devProvisioner := openrouter.NewDevelopment("test-openrouter-key") - chatClient := openrouter.NewUnifiedClient(logger, guardianPolicy, devProvisioner, &openrouter.PlatformKeyResolver{Provisioner: devProvisioner}, nil, nil, nil, nil) - vectorToolStore := rag.NewToolsetVectorStore(logger, tracerProvider, conn, chatClient) - chatSessionsManager := chatsessions.NewManager(logger, redisClient, "test-jwt-secret") - logsEnabled := func(_ context.Context, _ string) (bool, error) { return true, nil } - toolIOLogsEnabled := func(_ context.Context, _ string) (bool, error) { return false, nil } - sessionCaptureEnabled := func(_ context.Context, _ string) (bool, error) { return true, nil } - - telemLogger := telemetry.NewLogger(ctx, logger, testenv.NewTracerProvider(t), testenv.NewMeterProvider(t), chConn, logsEnabled, toolIOLogsEnabled, telemetry.NewUserInfoResolver(logger, conn, cacheAdapter), telemetry.NewNoopLogPublisher(testenv.NewLogger(t))) - telemService := telemetry.NewService(logger, tracerProvider, conn, chConn, sessionManager, chatSessionsManager, logsEnabled, sessionCaptureEnabled, posthogClient, authzEngine, nil) - - temporalEnv, _ := infra.NewTemporalEnv(t) - - assistantTokens := assistanttokens.New("test-jwt-secret", conn, authzEngine) - shadowMCPClient := shadowmcp.NewClient(logger, conn, cacheAdapter, nil) - auditLogger := audit.NewLogger() - userSessionSigner := usersessions.NewSigner("test-jwt-secret") - remoteChallengeMgr := remotesessions.NewChallengeManager(logger, tracerProvider, meterProvider, conn, enc, guardianPolicy, cacheAdapter, serverURL) - mcpToolExecutionCheckpoint, err := mcptoolexecution.NewCheckpoint(conn, mcptoolexecution.DefaultEvaluationTimeout, meterProvider, logger) - require.NoError(t, err) - remoteProxyManager := remotemcp.NewProxyManager(logger, tracerProvider, meterProvider, conn, guardianPolicy, authzEngine, posthogClient, telemLogger, billingClient, billingClient, mcpservers.NewToolDispositionCache(logger, conn, cacheAdapter), toolcallobserver.NoopSuccessRecorder{}, toolfilter.NewSessionToolWitnessStore(testenv.NewLogger(t), testenv.NewMemoryCache()), mcpToolExecutionCheckpoint) - mcpService, err := mcp.NewService(logger, tracerProvider, meterProvider, conn, sessionManager, chatSessionsManager, env, posthogClient, &feature.InMemory{}, serverURL, serverURL, enc, cacheAdapter, guardianPolicy, funcs, billingClient, billingClient, telemLogger, telemService, vectorToolStore, nil, temporalEnv, authzEngine, assistantTokens, shadowMCPClient, auditLogger, nil, nil, nil, nil, userSessionSigner, remoteChallengeMgr, remoteProxyManager, route.NewRouteTable(), "", nil, nil, mcp.TunnelPublicConfig{ - SessionTTL: 0, - LiveSessionCap: 0, - InitializeRate: ratelimit.Rate{Tokens: 0, Interval: 0, Burst: 0}, - RequestRate: ratelimit.Rate{Tokens: 0, Interval: 0, Burst: 0}, - MaxRequestLifetime: 0, - }, mcp.MetaRuntimeConfig{MemberCallTimeout: 0}) - require.NoError(t, err) - - svc := xmcp.NewService(logger, conn, enc, mcpService) - - return ctx, &testInstance{ - service: svc, - mcpService: mcpService, - conn: conn, - sessionManager: sessionManager, - tracerProvider: tracerProvider, - logger: logger, - enc: enc, - authzEngine: authzEngine, - shadowMCPClient: shadowMCPClient, - cacheAdapter: cacheAdapter, - serverURL: serverURL, - } -} - -// seedAPIKey inserts a new API key row for the given project and returns the -// raw key the client should send in Authorization. The key is stored using -// the same SHA-256 hash the auth layer expects. -func seedAPIKey(t *testing.T, ctx context.Context, ti *testInstance, organizationID string, userID string, projectID *uuid.UUID, scopes []string) string { - t.Helper() - - raw := make([]byte, 24) - _, err := rand.Read(raw) - require.NoError(t, err) - fullKey := "gram_test_" + hex.EncodeToString(raw) - - hash, err := auth.GetAPIKeyHash(fullKey) - require.NoError(t, err) - - var pgProjectID uuid.NullUUID - if projectID != nil { - pgProjectID = uuid.NullUUID{UUID: *projectID, Valid: true} - } - - _, err = keysrepo.New(ti.conn).CreateAPIKey(ctx, keysrepo.CreateAPIKeyParams{ - OrganizationID: organizationID, - ProjectID: pgProjectID, - CreatedByUserID: userID, - Name: "xmcp-test-" + uuid.NewString()[:8], - KeyPrefix: fullKey[:10], - KeyHash: hash, - Scopes: scopes, - }) - require.NoError(t, err) - - return fullKey -} - -// seedRemoteMCPServer inserts a new remote_mcp_servers row and any configured -// headers, encrypting secret values the same way the management API does. -func seedRemoteMCPServer(t *testing.T, ctx context.Context, ti *testInstance, projectID uuid.UUID, url string, headers ...remotemcprepo.CreateServerHeaderParams) remotemcprepo.RemoteMcpServer { - t.Helper() - - r := remotemcprepo.New(ti.conn) - server := remotemcptest.SeedServer(t, ctx, ti.conn, remotemcprepo.CreateServerParams{ - ProjectID: projectID, - TransportType: "streamable-http", - Url: url, - }) - - for _, h := range headers { - params := h - params.RemoteMcpServerID = server.ID - params.ProjectID = projectID - - if params.IsSecret && params.Value.Valid && params.Value.String != "" { - encrypted, encErr := ti.enc.Encrypt([]byte(params.Value.String)) - require.NoError(t, encErr) - params.Value = pgtype.Text{String: encrypted, Valid: true} - } - - _, err := r.CreateServerHeader(ctx, params) - require.NoError(t, err) - } - - return server -} - -// randomSlug returns a unique mcp_endpoints.slug suitable for parallel -// tests. Full UUID entropy keeps collisions out of birthday range even -// at high parallelism. -func randomSlug() string { - return "xmcp-test-" + uuid.NewString() -} - -// seedRemoteMCPEndpoint wires up a full /x/mcp/{slug} resolution chain for a -// remote-backed mcp_server: a remote_mcp_servers row + an mcp_servers row -// pointing at it + an mcp_endpoints row exposing it via the returned slug. -// visibility must be "public", "private", or "disabled". -func seedRemoteMCPEndpoint(t *testing.T, ctx context.Context, ti *testInstance, projectID uuid.UUID, upstreamURL, visibility string, headers ...remotemcprepo.CreateServerHeaderParams) (slug string, mcpServer mcpserversrepo.McpServer, remoteServer remotemcprepo.RemoteMcpServer) { - t.Helper() - - remoteServer = seedRemoteMCPServer(t, ctx, ti, projectID, upstreamURL, headers...) - mcpServerID, err := uuid.NewV7() - require.NoError(t, err) - issuerID := seedUserSessionIssuer(t, ctx, ti, projectID) - mcpServer, err = mcpserversrepo.New(ti.conn).CreateMCPServer(ctx, mcpserversrepo.CreateMCPServerParams{ - ID: mcpServerID, - ProjectID: projectID, - Name: conv.ToPGText("test mcp server"), - Slug: conv.ToPGText("test-mcp-server-" + mcpServerID.String()[len(mcpServerID.String())-4:]), - EnvironmentID: uuid.NullUUID{}, - RemoteMcpServerID: uuid.NullUUID{UUID: remoteServer.ID, Valid: true}, - ToolsetID: uuid.NullUUID{}, - Visibility: visibility, - UserSessionIssuerID: uuid.NullUUID{UUID: issuerID, Valid: true}, - }) - require.NoError(t, err) - - slug = randomSlug() - _, err = mcpendpointsrepo.New(ti.conn).CreateMCPEndpoint(ctx, mcpendpointsrepo.CreateMCPEndpointParams{ - ProjectID: projectID, - CustomDomainID: uuid.NullUUID{}, - McpServerID: uuid.NullUUID{UUID: mcpServer.ID, Valid: true}, - Slug: slug, - }) - require.NoError(t, err) - - return slug, mcpServer, remoteServer -} - -// seedToolsetMCPEndpoint wires up a full /x/mcp/{slug} resolution chain for -// a toolset-backed mcp_server: an mcp_servers row pointing at the given -// toolset + an mcp_endpoints row exposing it under the toolset's mcp_slug. -// The endpoint slug intentionally mirrors the toolset's mcp_slug — the -// production model assumes the two stay aligned until OAuth handling is -// migrated off toolsets onto mcp_servers (AGE-1902). -func seedToolsetMCPEndpoint(t *testing.T, ctx context.Context, ti *testInstance, projectID uuid.UUID, toolset toolsetsrepo.Toolset, visibility string) (slug string, mcpServer mcpserversrepo.McpServer) { - t.Helper() - return seedToolsetMCPEndpointOnDomain(t, ctx, ti, projectID, toolset, visibility, uuid.NullUUID{}) -} - -// seedToolsetMCPEndpointOnDomain is the custom-domain-aware variant of -// seedToolsetMCPEndpoint. Pass a Valid customDomainID to scope the -// resulting mcp_endpoint to that domain so it resolves only when a request -// arrives with a matching customdomains.Context. -func seedToolsetMCPEndpointOnDomain(t *testing.T, ctx context.Context, ti *testInstance, projectID uuid.UUID, toolset toolsetsrepo.Toolset, visibility string, customDomainID uuid.NullUUID) (slug string, mcpServer mcpserversrepo.McpServer) { - t.Helper() - - mcpServerID, err := uuid.NewV7() - require.NoError(t, err) - mcpServer, err = mcpserversrepo.New(ti.conn).CreateMCPServer(ctx, mcpserversrepo.CreateMCPServerParams{ - ID: mcpServerID, - ProjectID: projectID, - Name: conv.ToPGText("test mcp server"), - Slug: conv.ToPGText("test-mcp-server-" + mcpServerID.String()[len(mcpServerID.String())-4:]), - EnvironmentID: uuid.NullUUID{}, - RemoteMcpServerID: uuid.NullUUID{}, - ToolsetID: uuid.NullUUID{UUID: toolset.ID, Valid: true}, - Visibility: visibility, - }) - require.NoError(t, err) - - slug = toolset.McpSlug.String - _, err = mcpendpointsrepo.New(ti.conn).CreateMCPEndpoint(ctx, mcpendpointsrepo.CreateMCPEndpointParams{ - ProjectID: projectID, - CustomDomainID: customDomainID, - McpServerID: uuid.NullUUID{UUID: mcpServer.ID, Valid: true}, - Slug: slug, - }) - require.NoError(t, err) - - return slug, mcpServer -} - -// seedCustomDomain creates a verified+activated custom_domains row in the -// caller's organization. Verification is forced on so the row is treated as -// active by the runtime resolution code paths. -func seedCustomDomain(t *testing.T, ctx context.Context, ti *testInstance, organizationID, domainName string) customdomainsrepo.CustomDomain { - t.Helper() - - r := customdomainsrepo.New(ti.conn) - domain, err := r.CreateCustomDomain(ctx, customdomainsrepo.CreateCustomDomainParams{ - OrganizationID: organizationID, - Domain: domainName, - IngressName: pgtype.Text{String: "", Valid: false}, - CertSecretName: pgtype.Text{String: "", Valid: false}, - IpAllowlist: []string{}, - }) - require.NoError(t, err) - - domain, err = r.UpdateCustomDomain(ctx, customdomainsrepo.UpdateCustomDomainParams{ - ID: domain.ID, - Verified: true, - Activated: true, - IngressName: pgtype.Text{String: "", Valid: false}, - CertSecretName: pgtype.Text{String: "", Valid: false}, - }) - require.NoError(t, err) - return domain -} - -// runHandler invokes the xmcp handler against a custom method/path with chi -// URL params populated. -func runHandler(t *testing.T, ctx context.Context, ti *testInstance, method, slug, authorization string, body []byte) *httptest.ResponseRecorder { - t.Helper() - - mux := chi.NewMux() - mux.MethodFunc(method, xmcp.RuntimePath, oops.ErrHandle(ti.logger, ti.service.ServeMCP).ServeHTTP) - - req := httptest.NewRequestWithContext(ctx, method, "/x/mcp/"+slug, bytes.NewReader(body)) - req.Header.Set("Content-Type", "application/json") - // MCP Streamable HTTP § Sending Messages to the Server (step 2) requires - // clients to list both application/json and text/event-stream on POST. - req.Header.Set("Accept", "application/json, text/event-stream") - if authorization != "" { - req.Header.Set("Authorization", authorization) - } - - w := httptest.NewRecorder() - mux.ServeHTTP(w, req) - return w -} - -// bearer builds an Authorization header value for the given raw key. -func bearer(key string) string { - return fmt.Sprintf("Bearer %s", key) -} - -// seedUserSessionIssuer inserts a user_session_issuers row in the given -// project, returning the row's id. Mirrors the seed pattern used by the -// mcpservers tests so the resulting issuer is structurally identical to -// what the management API would produce. -func seedUserSessionIssuer(t *testing.T, ctx context.Context, ti *testInstance, projectID uuid.UUID) uuid.UUID { - t.Helper() - issuer, err := usersessionsrepo.New(ti.conn).CreateUserSessionIssuer(ctx, usersessionsrepo.CreateUserSessionIssuerParams{ - ProjectID: projectID, - Slug: "issuer-" + uuid.NewString(), - AuthnChallengeMode: "chain", - SessionDuration: pgtype.Interval{Microseconds: time.Hour.Microseconds(), Days: 0, Months: 0, Valid: true}, - }) - require.NoError(t, err) - return issuer.ID -} - -// seedIssuerGatedToolsetMCPEndpoint wires up a full /x/mcp/{slug} -// resolution chain for a toolset-backed mcp_server with -// user_session_issuer_id set on the mcp_servers row. Mirrors -// seedIssuerGatedRemoteMCPEndpoint for the toolset branch of -// ServeMCP. Returns the endpoint slug, the resulting mcp_server row, -// and the issuer id that gates it. -func seedIssuerGatedToolsetMCPEndpoint( - t *testing.T, - ctx context.Context, - ti *testInstance, - organizationID string, - projectID uuid.UUID, - visibility string, -) (slug string, mcpServer mcpserversrepo.McpServer, issuerID uuid.UUID) { - t.Helper() - - issuerID = seedUserSessionIssuer(t, ctx, ti, projectID) - - toolsetSlug := "tsl-" + uuid.NewString() - toolset, err := toolsetsrepo.New(ti.conn).CreateToolset(ctx, toolsetsrepo.CreateToolsetParams{ - OrganizationID: organizationID, - ProjectID: projectID, - Name: "issuer-gated " + toolsetSlug[:20], - Slug: toolsetSlug, - Description: pgtype.Text{String: "issuer-gated /x/mcp test", Valid: true}, - DefaultEnvironmentSlug: pgtype.Text{String: "", Valid: false}, - McpSlug: pgtype.Text{String: toolsetSlug, Valid: true}, - McpEnabled: true, - }) - require.NoError(t, err) - - // Align toolset.mcp_is_public with the mcp_server visibility so the - // toolset-backed branch inside ServeToolsetResolved evaluates against - // the same public/private intent the /x/mcp caller observed. - if visibility == mcpservers.VisibilityPublic { - _, err = toolsetsrepo.New(ti.conn).UpdateToolset(ctx, toolsetsrepo.UpdateToolsetParams{ - Name: toolset.Name, - Description: toolset.Description, - DefaultEnvironmentSlug: toolset.DefaultEnvironmentSlug, - McpSlug: toolset.McpSlug, - McpIsPublic: true, - McpEnabled: toolset.McpEnabled, - Slug: toolset.Slug, - ProjectID: toolset.ProjectID, - }) - require.NoError(t, err) - } - - mcpServerID, err := uuid.NewV7() - require.NoError(t, err) - mcpServer, err = mcpserversrepo.New(ti.conn).CreateMCPServer(ctx, mcpserversrepo.CreateMCPServerParams{ - ID: mcpServerID, - ProjectID: projectID, - Name: conv.ToPGText("issuer-gated toolset"), - Slug: conv.ToPGText("issuer-gated-toolset-" + mcpServerID.String()[len(mcpServerID.String())-4:]), - EnvironmentID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, - UserSessionIssuerID: uuid.NullUUID{UUID: issuerID, Valid: true}, - RemoteMcpServerID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, - ToolsetID: uuid.NullUUID{UUID: toolset.ID, Valid: true}, - Visibility: visibility, - }) - require.NoError(t, err) - - slug = randomSlug() - _, err = mcpendpointsrepo.New(ti.conn).CreateMCPEndpoint(ctx, mcpendpointsrepo.CreateMCPEndpointParams{ - ProjectID: projectID, - CustomDomainID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, - McpServerID: uuid.NullUUID{UUID: mcpServer.ID, Valid: true}, - Slug: slug, - }) - require.NoError(t, err) - return slug, mcpServer, issuerID -} - -// seedIssuerGatedRemoteMCPEndpoint wires up a full /x/mcp/{slug} -// resolution chain for a remote-backed mcp_server with -// user_session_issuer_id set, simulating an /x/mcp endpoint configured -// for issuer-gated OAuth. Returns the slug, the resulting mcp_server row, -// and the issuer id that gates it. -func seedIssuerGatedRemoteMCPEndpoint( - t *testing.T, - ctx context.Context, - ti *testInstance, - projectID uuid.UUID, - upstreamURL, visibility string, -) (slug string, mcpServer mcpserversrepo.McpServer, issuerID uuid.UUID) { - t.Helper() - return seedIssuerGatedRemoteMCPEndpointOnDomain(t, ctx, ti, projectID, upstreamURL, visibility, uuid.NullUUID{}) -} - -// seedIssuerGatedRemoteMCPEndpointOnDomain is the custom-domain-aware -// variant of seedIssuerGatedRemoteMCPEndpoint. Pass a Valid customDomainID -// to scope the resulting mcp_endpoint to that domain so it resolves only -// when a request arrives with a matching customdomains.Context. -func seedIssuerGatedRemoteMCPEndpointOnDomain( - t *testing.T, - ctx context.Context, - ti *testInstance, - projectID uuid.UUID, - upstreamURL, visibility string, - customDomainID uuid.NullUUID, -) (slug string, mcpServer mcpserversrepo.McpServer, issuerID uuid.UUID) { - t.Helper() - - issuerID = seedUserSessionIssuer(t, ctx, ti, projectID) - remoteServer := seedRemoteMCPServer(t, ctx, ti, projectID, upstreamURL) - - mcpServerID, err := uuid.NewV7() - require.NoError(t, err) - mcpServer, err = mcpserversrepo.New(ti.conn).CreateMCPServer(ctx, mcpserversrepo.CreateMCPServerParams{ - ID: mcpServerID, - ProjectID: projectID, - Name: conv.ToPGText("issuer-gated remote"), - Slug: conv.ToPGText("issuer-gated-remote-" + mcpServerID.String()[len(mcpServerID.String())-4:]), - EnvironmentID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, - UserSessionIssuerID: uuid.NullUUID{UUID: issuerID, Valid: true}, - RemoteMcpServerID: uuid.NullUUID{UUID: remoteServer.ID, Valid: true}, - ToolsetID: uuid.NullUUID{UUID: uuid.Nil, Valid: false}, - Visibility: visibility, - }) - require.NoError(t, err) - - slug = randomSlug() - _, err = mcpendpointsrepo.New(ti.conn).CreateMCPEndpoint(ctx, mcpendpointsrepo.CreateMCPEndpointParams{ - ProjectID: projectID, - CustomDomainID: customDomainID, - McpServerID: uuid.NullUUID{UUID: mcpServer.ID, Valid: true}, - Slug: slug, - }) - require.NoError(t, err) - return slug, mcpServer, issuerID -} diff --git a/server/internal/xmcp/wellknown.go b/server/internal/xmcp/wellknown.go deleted file mode 100644 index 38a786c685f..00000000000 --- a/server/internal/xmcp/wellknown.go +++ /dev/null @@ -1,75 +0,0 @@ -package xmcp - -import ( - "fmt" - "net/http" - - "github.com/go-chi/chi/v5" - - "github.com/speakeasy-api/gram/server/internal/attr" - "github.com/speakeasy-api/gram/server/internal/oops" -) - -// HandleWellKnownOAuthServerMetadata serves -// /.well-known/oauth-authorization-server/x/mcp/{mcpSlug}. -// -// Resolution walks slug → mcp_endpoint → mcp_server, then delegates to the -// shared per-backend dispatch on mcp.Service with the "x/mcp" route base. -// Unlike /mcp, /x/mcp has no legacy toolsets.mcp_slug fallback — it is a -// fresh surface keyed entirely on mcp_endpoints. See -// [mcp.Service.ServeWellKnownAuthorizationServerForServer] for the -// per-backend semantics (issuer-gated → Gram-hosted metadata; remote-backed -// → 404; toolset-backed → legacy wellknown resolver). -func (s *Service) HandleWellKnownOAuthServerMetadata(w http.ResponseWriter, r *http.Request) error { - ctx := r.Context() - slug := chi.URLParam(r, "mcpSlug") - if slug == "" { - return oops.E(oops.CodeBadRequest, nil, "an mcp slug must be provided") - } - - logger := s.logger.With(attr.SlogToolsetMCPSlug(slug)) - - endpoint, mcpServer, metaServer, err := s.mcpService.ResolveMCPEndpointAndServer(ctx, logger, slug) - if err != nil { - return fmt.Errorf("resolve mcp endpoint: %w", err) - } - - // Meta-backed endpoints are served only on the canonical /mcp surface. - if metaServer != nil { - return oops.E(oops.CodeNotFound, nil, "mcp endpoint not found") - } - - if err := s.mcpService.ServeWellKnownAuthorizationServerForServer(w, r, logger, endpoint, mcpServer, "x/mcp"); err != nil { - return fmt.Errorf("serve oauth authorization server metadata: %w", err) - } - return nil -} - -// HandleWellKnownOAuthProtectedResourceMetadata serves -// /.well-known/oauth-protected-resource/x/mcp/{mcpSlug}. Same resolution -// model as [Service.HandleWellKnownOAuthServerMetadata]; the emitted resource -// URL is the runtime URL the caller addressed (`/x/mcp/`). -func (s *Service) HandleWellKnownOAuthProtectedResourceMetadata(w http.ResponseWriter, r *http.Request) error { - ctx := r.Context() - slug := chi.URLParam(r, "mcpSlug") - if slug == "" { - return oops.E(oops.CodeBadRequest, nil, "an mcp slug must be provided") - } - - logger := s.logger.With(attr.SlogToolsetMCPSlug(slug)) - - endpoint, mcpServer, metaServer, err := s.mcpService.ResolveMCPEndpointAndServer(ctx, logger, slug) - if err != nil { - return fmt.Errorf("resolve mcp endpoint: %w", err) - } - - // Meta-backed endpoints are served only on the canonical /mcp surface. - if metaServer != nil { - return oops.E(oops.CodeNotFound, nil, "mcp endpoint not found") - } - - if err := s.mcpService.ServeWellKnownProtectedResourceForServer(w, r, logger, endpoint, mcpServer, "x/mcp"); err != nil { - return fmt.Errorf("serve oauth protected resource metadata: %w", err) - } - return nil -} diff --git a/server/internal/xmcp/wellknown_test.go b/server/internal/xmcp/wellknown_test.go deleted file mode 100644 index 6507b259940..00000000000 --- a/server/internal/xmcp/wellknown_test.go +++ /dev/null @@ -1,525 +0,0 @@ -// wellknown_test.go covers the experimental /.well-known/.../x/mcp/{slug} -// routes. The xmcp handlers walk slug → mcp_endpoint → mcp_server and -// dispatch per-backend (toolset vs remote), so these tests exercise: -// -// - Path validation (missing slug, unknown slug, disabled server). -// - Remote-backed dispatch returning 404 — gated on a separate upcoming -// OAuth migration (independent of AGE-1902). -// - Toolset-backed dispatch reusing the existing wellknown resolvers, with -// proxy and external-OAuth happy paths covered. -// -// The production model assumes mcp_endpoints.slug == toolsets.mcp_slug for -// toolset-backed servers until the upcoming OAuth migration moves the OAuth -// machinery onto mcp_servers. seedToolsetMCPEndpoint mirrors that assumption. -package xmcp_test - -import ( - "context" - "encoding/json" - "net/http" - "net/http/httptest" - "testing" - - "github.com/go-chi/chi/v5" - "github.com/google/uuid" - "github.com/jackc/pgx/v5/pgtype" - "github.com/stretchr/testify/require" - - "github.com/speakeasy-api/gram/server/internal/contextvalues" - "github.com/speakeasy-api/gram/server/internal/conv" - "github.com/speakeasy-api/gram/server/internal/customdomains" - "github.com/speakeasy-api/gram/server/internal/oauthtest" - "github.com/speakeasy-api/gram/server/internal/testenv/testrepo" - toolsetsrepo "github.com/speakeasy-api/gram/server/internal/toolsets/repo" - "github.com/speakeasy-api/gram/server/internal/usersessions/cimd/admission" -) - -// runWellKnown invokes the supplied xmcp well-known handler with the chi -// `mcpSlug` URL param set to slug. The empty slug case is supported by -// passing slug="" — chi.URLParam returns "" both when the param is missing -// and when it is explicitly empty, which matches production routing. -func runWellKnown( - t *testing.T, - ctx context.Context, - handler func(http.ResponseWriter, *http.Request) error, - path, slug string, -) (*httptest.ResponseRecorder, error) { - t.Helper() - - req := httptest.NewRequestWithContext(ctx, http.MethodGet, path, nil) - rctx := chi.NewRouteContext() - rctx.URLParams.Add("mcpSlug", slug) - req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, rctx)) - - w := httptest.NewRecorder() - err := handler(w, req) - return w, err -} - -// seedBareToolset creates a toolset with the given mcp_slug and no OAuth -// configuration. Used for the "no OAuth configured" branches. -func seedBareToolset(t *testing.T, ctx context.Context, ti *testInstance, projectID uuid.UUID, organizationID, mcpSlug string) toolsetsrepo.Toolset { - t.Helper() - - toolset, err := toolsetsrepo.New(ti.conn).CreateToolset(ctx, toolsetsrepo.CreateToolsetParams{ - OrganizationID: organizationID, - ProjectID: projectID, - Name: "xmcp-bare-" + mcpSlug, - Slug: mcpSlug, - Description: conv.ToPGText("xmcp wellknown_test bare toolset"), - DefaultEnvironmentSlug: pgtype.Text{String: "", Valid: false}, - McpSlug: conv.ToPGText(mcpSlug), - McpEnabled: true, - }) - require.NoError(t, err) - return toolset -} - -// --------------------------------------------------------------------------- -// HandleWellKnownOAuthServerMetadata -// --------------------------------------------------------------------------- - -func TestHandleWellKnownOAuthServerMetadata_MissingSlug(t *testing.T) { - t.Parallel() - - _, ti := newTestService(t) - - w, err := runWellKnown(t, t.Context(), ti.service.HandleWellKnownOAuthServerMetadata, "/.well-known/oauth-authorization-server/x/mcp/", "") - require.Error(t, err) - require.Contains(t, err.Error(), "mcp slug must be provided") - require.Empty(t, w.Body.String()) -} - -func TestHandleWellKnownOAuthServerMetadata_EndpointNotFound(t *testing.T) { - t.Parallel() - - _, ti := newTestService(t) - - w, err := runWellKnown(t, t.Context(), ti.service.HandleWellKnownOAuthServerMetadata, "/.well-known/oauth-authorization-server/x/mcp/none", "definitely-missing-"+uuid.NewString()[:8]) - require.Error(t, err) - require.Contains(t, err.Error(), "mcp endpoint not found") - require.Empty(t, w.Body.String()) -} - -func TestHandleWellKnownOAuthServerMetadata_DisabledServer(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "disabled") - - w, err := runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthServerMetadata, "/.well-known/oauth-authorization-server/x/mcp/"+slug, slug) - require.Error(t, err) - require.Empty(t, w.Body.String()) -} - -func TestHandleWellKnownOAuthServerMetadata_ToolsetBackendWithoutOAuth(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - toolset := seedBareToolset(t, ctx, ti, *authCtx.ProjectID, authCtx.ActiveOrganizationID, "ts-noauth-"+uuid.NewString()[:8]) - slug, _ := seedToolsetMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, toolset, "public") - - w, err := runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthServerMetadata, "/.well-known/oauth-authorization-server/x/mcp/"+slug, slug) - require.Error(t, err) - require.Contains(t, err.Error(), "no OAuth configuration found") - require.Empty(t, w.Body.String()) -} - -func TestHandleWellKnownOAuthServerMetadata_ToolsetBackendWithExternalOAuth(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - external := oauthtest.CreateExternalOAuthToolset(t, ctx, ti.conn, authCtx, oauthtest.ExternalOAuthToolsetOpts{ - Slug: "xmcp-srv-external", - IsPublic: true, - Metadata: nil, - }) - slug, _ := seedToolsetMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, external.Toolset, "public") - - w, err := runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthServerMetadata, "/.well-known/oauth-authorization-server/x/mcp/"+slug, slug) - require.NoError(t, err) - require.Equal(t, http.StatusOK, w.Code) - require.Contains(t, w.Header().Get("Content-Type"), "application/json") - - // External OAuth toolsets re-serve the upstream provider's captured - // metadata, but the issuer is rewritten to the Gram resource URL so the - // document satisfies RFC 8414 §3.3 (served issuer must equal the URL the - // client fetched it under, i.e. the /x/mcp/{slug} surface). The upstream's - // own authorization/token endpoints are preserved verbatim. - var metadata map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &metadata)) - require.Equal(t, "http://0.0.0.0/x/mcp/"+slug, metadata["issuer"]) - require.Equal(t, "https://test-oauth-server.example.com/authorize", metadata["authorization_endpoint"]) - require.Equal(t, "https://test-oauth-server.example.com/token", metadata["token_endpoint"]) -} - -// TestHandleWellKnownOAuthServerMetadata_IssuerGatedRemoteBackend verifies -// the well-known authorization-server handler dispatches issuer-gated -// remote-backed mcp_servers through mcp.Service.ServeGetAuthorizationServer -// (previously a 404). The advertised issuer + endpoint URLs are rooted -// at /x/mcp/, pointing MCP clients at the matching OAuth handler -// family registered by xmcp.Attach. -func TestHandleWellKnownOAuthServerMetadata_IssuerGatedRemoteBackend(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, _ := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public") - - w, err := runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthServerMetadata, "/.well-known/oauth-authorization-server/x/mcp/"+slug, slug) - require.NoError(t, err) - require.Equal(t, http.StatusOK, w.Code) - require.Equal(t, "public, max-age=60", w.Header().Get("Cache-Control")) - require.NotEmpty(t, w.Header().Get("ETag")) - - var metadata map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &metadata)) - - expectedIssuer := "http://0.0.0.0/x/mcp/" + slug - require.Equal(t, expectedIssuer, metadata["issuer"]) - require.Equal(t, expectedIssuer+"/authorize", metadata["authorization_endpoint"]) - require.Equal(t, expectedIssuer+"/token", metadata["token_endpoint"]) - require.Equal(t, expectedIssuer+"/register", metadata["registration_endpoint"]) - require.Equal(t, expectedIssuer+"/revoke", metadata["revocation_endpoint"]) - - // RFC 9207 §3. Asserted per surface because the route base is baked into - // the issuer that `iss` has to match, so a regression can land on one - // surface while the other stays correct. - require.Equal(t, true, metadata["authorization_response_iss_parameter_supported"]) -} - -// TestHandleWellKnownOAuthServerMetadata_IssuerGatedToolsetBackend mirrors -// the remote-backed test for the toolset backend. The xmcp wellknown -// handler branches on backend after the issuer check, so both backends -// need their own coverage to catch a regression that swaps the branches. -func TestHandleWellKnownOAuthServerMetadata_IssuerGatedToolsetBackend(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, _ := seedIssuerGatedToolsetMCPEndpoint(t, ctx, ti, authCtx.ActiveOrganizationID, *authCtx.ProjectID, "public") - - w, err := runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthServerMetadata, "/.well-known/oauth-authorization-server/x/mcp/"+slug, slug) - require.NoError(t, err) - require.Equal(t, http.StatusOK, w.Code) - - var metadata map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &metadata)) - - expectedIssuer := "http://0.0.0.0/x/mcp/" + slug - require.Equal(t, expectedIssuer, metadata["issuer"]) - require.Equal(t, expectedIssuer+"/authorize", metadata["authorization_endpoint"]) -} - -// TestHandleWellKnownOAuthServerMetadata_IssuerGatedRemoteBackend_DanglingIssuerFKReturnsNotFound -// covers the race window where the user_session_issuer FK target has been -// deleted between mcp_servers row resolution and metadata emission. -// Symmetric to TestRequireUserSessionIssuer_DanglingFKReturnsNotFound but -// exercises the wellknown surface rather than ServeMCP. -func TestHandleWellKnownOAuthServerMetadata_IssuerGatedRemoteBackend_DanglingIssuerFKReturnsNotFound(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, issuerID := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public") - - // Sanity check: well-known resolves cleanly before deletion. - w, err := runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthServerMetadata, "/.well-known/oauth-authorization-server/x/mcp/"+slug, slug) - require.NoError(t, err) - require.Equal(t, http.StatusOK, w.Code) - - err = testrepo.New(ti.conn).ForceSoftDeleteUserSessionIssuer(ctx, testrepo.ForceSoftDeleteUserSessionIssuerParams{ - ID: issuerID, - ProjectID: *authCtx.ProjectID, - }) - require.NoError(t, err) - - w, err = runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthServerMetadata, "/.well-known/oauth-authorization-server/x/mcp/"+slug, slug) - require.Error(t, err, "dangling issuer FK must surface as a request-level error") - require.Contains(t, err.Error(), "user_session_issuer not found") - require.Empty(t, w.Body.String()) -} - -// --------------------------------------------------------------------------- -// HandleWellKnownOAuthProtectedResourceMetadata -// --------------------------------------------------------------------------- - -func TestHandleWellKnownOAuthProtectedResourceMetadata_MissingSlug(t *testing.T) { - t.Parallel() - - _, ti := newTestService(t) - - w, err := runWellKnown(t, t.Context(), ti.service.HandleWellKnownOAuthProtectedResourceMetadata, "/.well-known/oauth-protected-resource/x/mcp/", "") - require.Error(t, err) - require.Contains(t, err.Error(), "mcp slug must be provided") - require.Empty(t, w.Body.String()) -} - -func TestHandleWellKnownOAuthProtectedResourceMetadata_EndpointNotFound(t *testing.T) { - t.Parallel() - - _, ti := newTestService(t) - - w, err := runWellKnown(t, t.Context(), ti.service.HandleWellKnownOAuthProtectedResourceMetadata, "/.well-known/oauth-protected-resource/x/mcp/none", "definitely-missing-"+uuid.NewString()[:8]) - require.Error(t, err) - require.Contains(t, err.Error(), "mcp endpoint not found") - require.Empty(t, w.Body.String()) -} - -func TestHandleWellKnownOAuthProtectedResourceMetadata_DisabledServer(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, _ := seedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "disabled") - - w, err := runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthProtectedResourceMetadata, "/.well-known/oauth-protected-resource/x/mcp/"+slug, slug) - require.Error(t, err) - require.Empty(t, w.Body.String()) -} - -func TestHandleWellKnownOAuthProtectedResourceMetadata_ToolsetBackendWithoutOAuth(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - toolset := seedBareToolset(t, ctx, ti, *authCtx.ProjectID, authCtx.ActiveOrganizationID, "ts-prnoauth-"+uuid.NewString()[:8]) - slug, _ := seedToolsetMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, toolset, "public") - - w, err := runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthProtectedResourceMetadata, "/.well-known/oauth-protected-resource/x/mcp/"+slug, slug) - require.Error(t, err) - require.Contains(t, err.Error(), "no OAuth configuration found") - require.Empty(t, w.Body.String()) -} - -func TestHandleWellKnownOAuthProtectedResourceMetadata_ToolsetBackendOnCustomDomain(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - domain := seedCustomDomain(t, ctx, ti, authCtx.ActiveOrganizationID, "xmcp-pr-cd-"+uuid.NewString()[:8]+".example.com") - external := oauthtest.CreateExternalOAuthToolset(t, ctx, ti.conn, authCtx, oauthtest.ExternalOAuthToolsetOpts{ - Slug: "xmcp-pr-cd", - IsPublic: true, - Metadata: nil, - }) - slug, _ := seedToolsetMCPEndpointOnDomain(t, ctx, ti, *authCtx.ProjectID, external.Toolset, "public", uuid.NullUUID{UUID: domain.ID, Valid: true}) - - domainCtx := customdomains.WithContext(ctx, &customdomains.Context{ - OrganizationID: authCtx.ActiveOrganizationID, - Domain: domain.Domain, - DomainID: domain.ID, - }) - - w, err := runWellKnown(t, domainCtx, ti.service.HandleWellKnownOAuthProtectedResourceMetadata, "/.well-known/oauth-protected-resource/x/mcp/"+slug, slug) - require.NoError(t, err) - require.Equal(t, http.StatusOK, w.Code) - - var metadata map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &metadata)) - - expectedResource := "https://" + domain.Domain + "/x/mcp/" + slug - require.Equal(t, expectedResource, metadata["resource"]) - - authServers, ok := metadata["authorization_servers"].([]any) - require.True(t, ok) - require.Equal(t, []any{expectedResource}, authServers) -} - -func TestHandleWellKnownOAuthProtectedResourceMetadata_ToolsetBackendWithExternalOAuth(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - external := oauthtest.CreateExternalOAuthToolset(t, ctx, ti.conn, authCtx, oauthtest.ExternalOAuthToolsetOpts{ - Slug: "xmcp-pr-external", - IsPublic: true, - Metadata: nil, - }) - slug, _ := seedToolsetMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, external.Toolset, "public") - - w, err := runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthProtectedResourceMetadata, "/.well-known/oauth-protected-resource/x/mcp/"+slug, slug) - require.NoError(t, err) - require.Equal(t, http.StatusOK, w.Code) - - var metadata map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &metadata)) - require.Equal(t, "http://0.0.0.0/x/mcp/"+slug, metadata["resource"]) -} - -// TestHandleWellKnownOAuthProtectedResourceMetadata_IssuerGatedRemoteBackend -// verifies the well-known protected-resource handler dispatches issuer- -// gated remote-backed mcp_servers through the new mcp.Service.ServeGetProtectedResource -// path instead of returning 404. The emitted resource URL is the runtime -// URL the caller is actually addressing (`/x/mcp/`), and -// authorization_servers points at the same root so discovery loops back -// to the AS metadata. -func TestHandleWellKnownOAuthProtectedResourceMetadata_IssuerGatedRemoteBackend(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, _ := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public") - - w, err := runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthProtectedResourceMetadata, "/.well-known/oauth-protected-resource/x/mcp/"+slug, slug) - require.NoError(t, err) - require.Equal(t, http.StatusOK, w.Code) - require.Contains(t, w.Header().Get("Content-Type"), "application/json") - - var metadata map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &metadata)) - - expectedResource := "http://0.0.0.0/x/mcp/" + slug - require.Equal(t, expectedResource, metadata["resource"]) - - authServers, ok := metadata["authorization_servers"].([]any) - require.True(t, ok) - require.Equal(t, []any{expectedResource}, authServers) -} - -// TestHandleWellKnownOAuthProtectedResourceMetadata_IssuerGatedToolsetBackend -// is the toolset companion of the remote-backed test above. -func TestHandleWellKnownOAuthProtectedResourceMetadata_IssuerGatedToolsetBackend(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, _ := seedIssuerGatedToolsetMCPEndpoint(t, ctx, ti, authCtx.ActiveOrganizationID, *authCtx.ProjectID, "public") - - w, err := runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthProtectedResourceMetadata, "/.well-known/oauth-protected-resource/x/mcp/"+slug, slug) - require.NoError(t, err) - require.Equal(t, http.StatusOK, w.Code) - - var metadata map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &metadata)) - require.Equal(t, "http://0.0.0.0/x/mcp/"+slug, metadata["resource"]) -} - -// TestHandleWellKnownOAuthProtectedResourceMetadata_IssuerGatedRemoteBackend_OnCustomDomain -// asserts that an issuer-gated mcp_endpoint registered against a custom -// domain emits `https:///x/mcp/` as both resource and -// authorization_servers entry. Catches a regression that would otherwise -// emit the platform serverURL when the request arrived on a custom -// domain — clients reject discovery responses whose host doesn't match -// the resource they were directed to. -func TestHandleWellKnownOAuthProtectedResourceMetadata_IssuerGatedRemoteBackend_OnCustomDomain(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - domain := seedCustomDomain(t, ctx, ti, authCtx.ActiveOrganizationID, "xmcp-issuer-cd-"+uuid.NewString()[:8]+".example.com") - slug, _, _ := seedIssuerGatedRemoteMCPEndpointOnDomain(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public", uuid.NullUUID{UUID: domain.ID, Valid: true}) - - domainCtx := customdomains.WithContext(ctx, &customdomains.Context{ - OrganizationID: authCtx.ActiveOrganizationID, - Domain: domain.Domain, - DomainID: domain.ID, - }) - - w, err := runWellKnown(t, domainCtx, ti.service.HandleWellKnownOAuthProtectedResourceMetadata, "/.well-known/oauth-protected-resource/x/mcp/"+slug, slug) - require.NoError(t, err) - require.Equal(t, http.StatusOK, w.Code) - - var metadata map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &metadata)) - - expectedResource := "https://" + domain.Domain + "/x/mcp/" + slug - require.Equal(t, expectedResource, metadata["resource"]) - authServers, ok := metadata["authorization_servers"].([]any) - require.True(t, ok) - require.Equal(t, []any{expectedResource}, authServers) -} - -// TestHandleWellKnownOAuthServerMetadata_IssuerGated_CIMDAdvertised verifies -// the /x/mcp well-known variant advertises -// client_id_metadata_document_supported — the shared -// mcp.ServeGetAuthorizationServer emits it for both route families. -func TestHandleWellKnownOAuthServerMetadata_IssuerGated_CIMDAdvertised(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, _ := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public") - - w, err := runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthServerMetadata, "/.well-known/oauth-authorization-server/x/mcp/"+slug, slug) - require.NoError(t, err) - require.Equal(t, http.StatusOK, w.Code) - - var metadata map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &metadata)) - require.Equal(t, true, metadata["client_id_metadata_document_supported"]) -} - -// TestHandleWellKnownOAuthServerMetadata_IssuerGated_CIMDDisabledOmitted pins -// the omit-when-disabled behavior on the /x/mcp variant: an issuer whose -// admission mode is `disabled` must not advertise CIMD support. -func TestHandleWellKnownOAuthServerMetadata_IssuerGated_CIMDDisabledOmitted(t *testing.T) { - t.Parallel() - - ctx, ti := newTestService(t) - authCtx, ok := contextvalues.GetAuthContext(ctx) - require.True(t, ok) - require.NotNil(t, authCtx.ProjectID) - - slug, _, issuerID := seedIssuerGatedRemoteMCPEndpoint(t, ctx, ti, *authCtx.ProjectID, "https://upstream.invalid/mcp", "public") - - err := testrepo.New(ti.conn).SetUserSessionIssuerCIMDAdmissionMode(ctx, testrepo.SetUserSessionIssuerCIMDAdmissionModeParams{ - ClientIDMetadataAdmissionMode: conv.ToPGText(string(admission.ModeDisabled)), - ID: issuerID, - ProjectID: *authCtx.ProjectID, - }) - require.NoError(t, err) - - w, err := runWellKnown(t, ctx, ti.service.HandleWellKnownOAuthServerMetadata, "/.well-known/oauth-authorization-server/x/mcp/"+slug, slug) - require.NoError(t, err) - require.Equal(t, http.StatusOK, w.Code) - - var metadata map[string]any - require.NoError(t, json.Unmarshal(w.Body.Bytes(), &metadata)) - require.NotContains(t, metadata, "client_id_metadata_document_supported") -}