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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .speakeasy/out.openapi.yaml

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

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

2 changes: 0 additions & 2 deletions server/cmd/gram/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)

Expand Down Expand Up @@ -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))
Expand Down
2 changes: 1 addition & 1 deletion server/design/usersessions/design.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
})
Expand Down
2 changes: 1 addition & 1 deletion server/gen/http/openapi3.yaml

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

2 changes: 1 addition & 1 deletion server/gen/http/user_sessions/client/types.go

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

2 changes: 1 addition & 1 deletion server/gen/http/user_sessions/server/types.go

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

2 changes: 1 addition & 1 deletion server/gen/user_sessions/service.go

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

4 changes: 2 additions & 2 deletions server/internal/attr/conventions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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".
Expand Down
9 changes: 4 additions & 5 deletions server/internal/killswitches/mcptoolexecution/resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
54 changes: 18 additions & 36 deletions server/internal/mcp/authnchallenge.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"`
}

Expand Down Expand Up @@ -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 {
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
if domainCtx := customdomains.FromContext(r.Context()); domainCtx != nil {
return fmt.Sprintf("https://%s", domainCtx.Domain)
}
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 3 additions & 5 deletions server/internal/mcp/authnchallenge_authorize.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
12 changes: 4 additions & 8 deletions server/internal/mcp/authnchallenge_consent.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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:
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions server/internal/mcp/authnchallenge_consent_action.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 1 addition & 2 deletions server/internal/mcp/authnchallenge_consent_assets.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions server/internal/mcp/authnchallenge_consent_mcp_endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions server/internal/mcp/authnchallenge_consent_template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
17 changes: 6 additions & 11 deletions server/internal/mcp/authnchallenge_firstparty.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,20 +13,16 @@ 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")
if mcpSlug == "" {
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
}
Expand All @@ -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
Expand All @@ -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{
Expand Down
Loading
Loading