package mcpgateway import ( "context" "crypto/rand" "errors" "fmt" "log/slog" "maps" "net/http" "slices" "sort" "strings" "sync" "time" "github.com/modelcontextprotocol/go-sdk/mcp" "golang.org/x/sync/singleflight" "github.com/enterpilot/gomodel/config" "github.com/enterpilot/gomodel/ext" "github.com/enterpilot/gomodel/internal/core" "github.com/enterpilot/gomodel/internal/encryption" "github.com/enterpilot/gomodel/internal/usage" "github.com/enterpilot/gomodel/internal/version" ) // sessionIdleTimeout closes downstream sessions that stop sending requests, // and bounds the session-binding registry. const sessionIdleTimeout = 30 * time.Minute // ScopeHeader restricts a request's visible servers to a comma-separated // subset, so one gateway key can serve differently-scoped clients without // extra endpoints. Unknown names are ignored; an empty header means all. const ScopeHeader = "X-MCP-Servers" // labelsHeader carries the request labels resolved by the gateway (tagging // headers plus auth-key labels) from the HTTP layer into tool handlers, which // only see per-message HTTP headers. It is internal: the HTTP entrypoint // strips any client-sent value before stamping its own. const labelsHeader = "X-Gomodel-Mcp-Labels" // authKeyIDHeader carries the managed auth key id into tool handlers so MCP // usage rows can be attributed per key. Internal like labelsHeader. const authKeyIDHeader = "X-Gomodel-Mcp-Auth-Key-Id" // pinnedServerKey carries the /mcp/{server} path pin through the request // context into per-session server construction. type pinnedServerKey struct{} // virtualServerKey carries the /mcp/{name} virtual server the same way. type virtualServerKey struct{} // virtualBindingPrefix marks a session bound to a virtual server, so a session // opened on /mcp/{name} stops matching if a real server takes that path. "/" // never occurs in a slug, so the two kinds cannot collide. const virtualBindingPrefix = "virtual/" // Service is the MCP gateway: it merges declarative and admin-store server // specs into the upstream manager and serves the downstream MCP endpoints. type Service struct { manager *Manager store Store usageLogger usage.LoggerInterface userPathHeader string configSpecs map[string]ServerSpec // virtualConfig holds the declarative virtual servers; virtualStore the // admin-managed ones. virtualSpecs is their merge, rebuilt on reload. virtualConfig map[string]VirtualServerSpec virtualStore VirtualStore virtualMu sync.RWMutex virtualSpecs map[string]VirtualServerSpec // secrets resolves the secret references admin-managed headers hold. secrets *config.Secrets // oauth holds operator OAuth sign-ins to upstream servers; nil when the // store cannot persist them. oauth *oauthManager // reloadMu serializes reloads, so a slower one never applies an older // server set over a newer one. Close takes it too: an in-flight reload // finishes before the upstreams are closed, and reloadStopped turns later // ones into no-ops, so no upstream is dialed after shutdown. reloadMu sync.Mutex reloadStopped bool // stopCtx is cancelled by Close, so a reload blocked on a store read, // whether background, admin-triggered, or a secret rotation, does not // hold up shutdown. stopCtx context.Context stopCancel context.CancelFunc // mutateMu serializes admin saves and deletes from reading the stored // row through releasing the secrets it held, so none releases a secret // another one has just stored, or stores one another has just released. mutateMu sync.Mutex // recorded names the admin-managed servers whose references rotation // watches. recorded map[string]struct{} // skipped holds, by name, why the last full reload left out stored // servers whose references did not resolve. Guarded by reloadMu. skipped map[string]error // unresolved holds, by name, the last logged reason a stored server's // references did not resolve, so a reload retried every few seconds logs // a lasting failure once. Guarded by reloadMu. unresolved map[string]string // searchDiscovery is the default for sessions that do not send // ToolDiscoveryHeader: serve search_tools/call_tool instead of the catalog. searchDiscovery bool handler http.Handler origins *originGuard bindMu sync.Mutex bindings map[string]sessionBinding // shared holds session bindings for the other replicas, so a session // opened on one continues on any; nil keeps sessions on this instance. // See sharedsessions.go. shared ext.SharedStore // resumes collapses concurrent resumes of one session ID. resumes singleflight.Group // now stamps session bindings and their shared records. now func() time.Time // postTouchInterval is how often a POST in flight extends its shared // session's expiry; see trackPOST. postTouchInterval time.Duration requestMu sync.Mutex requestCancels map[uint64]context.CancelFunc nextRequestID uint64 closing bool stopOnce sync.Once stop chan struct{} // changed is called after every successful store write; see // SetChangeNotifier. changed func(context.Context) } // SetChangeNotifier installs fn, called after every successful write of a // server or virtual server so other gateway instances reload them. Call it // before serving. func (s *Service) SetChangeNotifier(fn func(context.Context)) { if s != nil { s.changed = fn } } func (s *Service) notifyChanged(ctx context.Context) { if s.changed != nil { s.changed(ctx) } } // errServiceClosed rejects a reload after Close. var errServiceClosed = errors.New("mcp gateway is shut down") // ErrServerNotVisible rejects a call from a session whose user path may no // longer use the server. var ErrServerNotVisible = errors.New("server is not available for this user path") // sessionBinding pins a downstream MCP session to the user path it was // initialized under. Bearer auth still runs on every request; the binding // additionally stops a *different* principal from riding a leaked session ID, // per the MCP session-hijacking guidance. type sessionBinding struct { authKeyID string userPath string // endpoint identifies the endpoint the session was opened on: a pinned // server slug, virtualBindingPrefix plus a virtual server name, or "" for // the aggregated endpoint. endpoint string lastSeen time.Time // virtualIncarnation is the Incarnation of the virtual server the // session opened on (see endpointReplaced); empty for any other endpoint. virtualIncarnation string // discovery and toolAliases let request logs name the tool a call runs: // a discovery session's call_tool target, and the namespaced name an // unambiguous bare name resolves to. discovery bool toolAliases map[string]string // server hosts the session; closeEndpointSessions closes it through it. server *mcp.Server // resumed is set when this replica rebuilt the session from the shared // store and serves it itself; nil when the SDK handler created it. resumed *resumedSession // shared reports that record, the session's encoded shared-store entry, // is stored. checkedAt and touchedAt are when this replica last read the // session's close marks and last extended the entry's expiry. shared bool record []byte checkedAt time.Time touchedAt time.Time // verifiedAt is when this replica last read the close marks // successfully, or opened or resumed the session; see // sessionUnverifiedLimit. verifiedAt time.Time // posts counts this replica's POSTs in flight for the session, which // pause its idle timeout, so sweepBindings keeps the binding. posts int } // boundTo reports whether a request's identity and endpoint are the ones the // session was opened under. func (b sessionBinding) boundTo(authKeyID, userPath, endpoint string) bool { return b.authKeyID == authKeyID && b.userPath == userPath && b.endpoint == endpoint } // Options configures NewService. type Options struct { // ConfigServers are the declarative servers from config.yaml / MCP_SERVERS. ConfigServers map[string]ServerSpec // VirtualServers are the declarative virtual servers, keyed by name. VirtualServers map[string]VirtualServerSpec // VirtualStore persists admin-managed virtual servers. Optional. VirtualStore VirtualStore // RefreshInterval re-reads the stores in the background, so admin edits // made on another gateway instance sharing the database reach this one. // Zero disables it. RefreshInterval time.Duration // Store persists admin-managed servers. Optional. Store Store // HTTPClient is the shared outbound HTTP client for http/sse upstreams. HTTPClient *http.Client // UsageLogger records one usage entry per tool call. Optional. UsageLogger usage.LoggerInterface // UserPathHeader is the configured user-path header name. UserPathHeader string // AllowedOrigins are the browser origins permitted to reach the endpoint. // Empty trusts none, which is the default; see originGuard. AllowedOrigins []string // ToolDiscovery is the default discovery mode, config.MCPToolDiscoveryOff // or config.MCPToolDiscoverySearch. ToolDiscovery string // Secrets resolves secret references in admin-managed header values. // Nil resolves the built-in env and file schemes only. Secrets *config.Secrets // oauthStore persists operator OAuth sign-ins; nil disables them. oauthStore *oauthStore // SharedStore shares downstream session bindings between the replicas on // one database, so a session continues on whichever replica a request // reaches. Nil keeps every session on the instance that opened it. SharedStore ext.SharedStore } // NewService builds the gateway service and starts connecting to the merged // server set. Upstream connects are asynchronous; construction never blocks. func NewService(ctx context.Context, opts Options) (*Service, error) { s := &Service{ manager: NewManager(opts.HTTPClient), store: opts.Store, usageLogger: opts.UsageLogger, userPathHeader: core.UserPathHeaderName(opts.UserPathHeader), configSpecs: opts.ConfigServers, virtualConfig: opts.VirtualServers, virtualStore: opts.VirtualStore, secrets: opts.Secrets, recorded: make(map[string]struct{}), unresolved: make(map[string]string), searchDiscovery: opts.ToolDiscovery == config.MCPToolDiscoverySearch, bindings: make(map[string]sessionBinding), shared: opts.SharedStore, now: time.Now, // Half the idle timeout: the record never lapses mid-call. postTouchInterval: sessionIdleTimeout / 2, requestCancels: make(map[uint64]context.CancelFunc), stop: make(chan struct{}), } if opts.oauthStore != nil { s.oauth = newOAuthManager(opts.oauthStore, opts.HTTPClient) } s.stopCtx, s.stopCancel = context.WithCancel(context.Background()) guard, err := newOriginGuard(opts.AllowedOrigins) if err != nil { return nil, fmt.Errorf("mcp allowed origins: %w", err) } s.origins = guard // The SDK's own DNS-rebinding guard stays enabled, but it only fires on // loopback connections by design, so originGuard — applied in ServeHTTP // ahead of this handler — is what defends every other bind address. s.handler = mcp.NewStreamableHTTPHandler(s.getServer, &mcp.StreamableHTTPOptions{ SessionTimeout: sessionIdleTimeout, Logger: slog.Default(), }) if err := s.Reload(ctx); err != nil { s.Close() return nil, err } go s.sweepBindings() if opts.RefreshInterval > 0 && (s.store != nil || s.virtualStore != nil) { go s.refreshLoop(opts.RefreshInterval) //nolint:gosec // Runs for the service lifetime, not the constructor call. } return s, nil } // Refresh re-reads the stores and reconciles, like Reload, but also reports // the stored servers whose references did not resolve, so the admin runtime // refresh shows them and the cluster sync poller retries them. func (s *Service) Refresh(ctx context.Context) error { unresolved, err := s.reload(ctx, nil) if err != nil { return err } return unresolved } func (s *Service) refreshLoop(interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-s.stop: return case <-ticker.C: ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) if err := s.Reload(ctx); err != nil && !errors.Is(err, errServiceClosed) && s.stopCtx.Err() == nil { slog.Error("failed to refresh mcp servers", "error", err) } cancel() } } } // Reload re-merges declarative and store specs and reconciles the upstream // set. Declarative entries shadow store rows with the same name, mirroring // the tagging/virtual-models source precedence. // // Secret references in stored headers are resolved here. A row whose // references do not resolve is logged and left out at startup; once its // server runs, it keeps its current headers instead, so a secret backend // outage never disconnects a working upstream. func (s *Service) Reload(ctx context.Context) error { _, err := s.reload(ctx, nil) return err } // reload is Reload resolving only the rows rotated reports true for; every // other running server keeps its current spec. A nil rotated resolves all. // It reports the rows whose references did not resolve apart from err, which // means nothing was applied. func (s *Service) reload(ctx context.Context, rotated func(name string) bool) (unresolved, err error) { // Close cancels every reload, not only background ones: an admin save // or a rotation waiting on a slow store read must not hold shutdown open. ctx, cancel := context.WithCancel(ctx) defer cancel() defer context.AfterFunc(s.stopCtx, cancel)() s.reloadMu.Lock() defer s.reloadMu.Unlock() if s.reloadStopped || s.stopCtx.Err() != nil { return nil, errServiceClosed } specs := make([]ServerSpec, 0, len(s.configSpecs)) seen := make(map[string]struct{}, len(s.configSpecs)) for name, spec := range s.configSpecs { specs = append(specs, spec) seen[name] = struct{}{} } var resolved []*config.ResolvedEntity stored := make(map[string]struct{}) skipped := make(map[string]error) var errs []error if s.store != nil { rows, err := s.store.List(ctx) if err != nil { return nil, fmt.Errorf("list managed mcp servers: %w", err) } for _, row := range rows { if _, shadowed := seen[row.Name]; shadowed { slog.Warn("mcp server from admin store is shadowed by config", "server", row.Name) continue } stored[row.Name] = struct{}{} current, running := s.manager.spec(row.Name) if running && rotated != nil && !rotated(row.Name) { specs = append(specs, current) continue } spec, secrets, err := s.resolveServer(ctx, row) if err != nil { errs = append(errs, err) repeated := s.unresolved[row.Name] == err.Error() s.unresolved[row.Name] = err.Error() if running { if !repeated { slog.Warn("mcp server secret references could not be resolved; keeping its current headers", "server", row.Name, "error", err) } specs = append(specs, current) } else { if !repeated { slog.Error("mcp server secret references could not be resolved; server skipped", "server", row.Name, "error", err) } skipped[row.Name] = err } continue } delete(s.unresolved, row.Name) specs = append(specs, spec) resolved = append(resolved, secrets) } } virtuals, err := s.mergedVirtualSpecs(ctx) if err != nil { return nil, err } s.virtualMu.Lock() s.virtualSpecs = virtuals s.virtualMu.Unlock() s.attachOAuth(ctx, specs) s.manager.Apply(specs) s.logVirtualServerIssues() for _, secrets := range resolved { secrets.Record() } for name := range s.recorded { if _, ok := stored[name]; !ok { s.secrets.ForgetEntity(serverSecretEntity(name)) } } s.recorded = stored for name := range s.unresolved { if _, ok := stored[name]; !ok { delete(s.unresolved, name) } } if rotated == nil { s.skipped = skipped } return errors.Join(errs...), nil } // mergedVirtualSpecs merges declarative and store virtual servers. Config // entries shadow store rows with the same name, like servers. func (s *Service) mergedVirtualSpecs(ctx context.Context) (map[string]VirtualServerSpec, error) { merged := make(map[string]VirtualServerSpec, len(s.virtualConfig)) maps.Copy(merged, s.virtualConfig) if s.virtualStore == nil { return merged, nil } rows, err := s.virtualStore.ListVirtual(ctx) if err != nil { return nil, fmt.Errorf("list managed mcp virtual servers: %w", err) } for _, row := range rows { if _, shadowed := merged[row.Name]; shadowed { slog.Warn("mcp virtual server from admin store is shadowed by config", "virtual_server", row.Name) continue } merged[row.Name] = row.Spec() } return merged, nil } // Skipped returns, by name, why the last full reload left out stored // servers whose secret references did not resolve. func (s *Service) Skipped() map[string]error { s.reloadMu.Lock() defer s.reloadMu.Unlock() return maps.Clone(s.skipped) } // Running reports whether the server name is enabled in the running upstream // set. A disabled server is kept there for the admin view but serves nothing. func (s *Service) Running(name string) bool { spec, ok := s.manager.spec(name) return ok && spec.Enabled } // Views returns the current admin snapshot of all servers, with their OAuth // sign-in state. func (s *Service) Views() []ServerView { views := s.manager.Views() for i := range views { views[i].OAuth = s.oauth.view(views[i].Spec) } return views } // IsManaged reports whether name is declared in config/env (read-only). func (s *Service) IsManaged(name string) bool { _, ok := s.configSpecs[name] return ok } // Upsert validates and persists one admin-managed server, then reconciles. // Config-declared names are read-only; stdio definitions are rejected at the // store boundary (declarative-only by design — see the gateway spec). func (s *Service) Upsert(ctx context.Context, server ManagedServer) error { if s.store == nil { return fmt.Errorf("mcp server persistence is unavailable") } if s.IsManaged(server.Name) { return fmt.Errorf("mcp server %q is managed by config/env and is read-only", server.Name) } if err := server.Validate(); err != nil { return err } // ErrSealUnconfirmed means the row was written: apply it like any other // save, then report the error. saveErr := s.persist(ctx, &server) if saveErr != nil && !errors.Is(saveErr, encryption.ErrSealUnconfirmed) { return saveErr } defer s.notifyChanged(ctx) if err := s.Reload(ctx); err != nil { // The row is persisted; only applying it to the running manager // failed. Say so — a retry or restart picks the row up. return errors.Join(saveErr, fmt.Errorf("mcp server %q was saved but not applied: %w", server.Name, err)) } return saveErr } // persist stores server in place of the row it replaces and releases the // secrets that row held and server no longer does. A name a virtual server // takes is refused with VirtualNameTakenError unless the row already exists. // An error wrapping encryption.ErrSealUnconfirmed means the row was written. func (s *Service) persist(ctx context.Context, server *ManagedServer) error { s.mutateMu.Lock() defer s.mutateMu.Unlock() previous, err := s.store.Get(ctx, server.Name) if err != nil && !errors.Is(err, ErrNotFound) { return err } write := s.store.Upsert // Stored rows, not the loaded list: a virtual server another instance // saved counts before this one refreshes. mutateMu, which virtual saves // hold too, keeps an overlapping save on this instance from slipping in. virtualTaken, err := s.virtualNameTaken(ctx, server.Name) if err != nil { return err } if virtualTaken { // Only a new server is refused: one stored before the virtual server // was declared keeps its endpoint and stays editable. Update fails // atomically if the row is gone, so an edit racing a delete cannot // recreate it under the virtual server's name. if previous == nil { return VirtualNameTakenError(server.Name) } write = s.store.Update } if err := config.CheckSecretDestinations(ctx, ServerSecretEntity, server.Name, serverDestinations(previous, server), headerValues(server)); err != nil { return err } // Literal headers go to the secret writer first, when one is registered, // so the row is stored with references only; then every reference must // resolve before anything is persisted. kept := headerValues(previous) written, err := s.storeServerSecrets(ctx, server, kept) if err != nil { return err } if _, _, err := s.resolveServer(ctx, *server); err != nil { // Nothing was stored, so whatever the stored row holds stays. s.releaseSecrets(ctx, server.Name, written, kept) return err } // ErrSealUnconfirmed means the row was written: treat it like any other // save, then report the error. saveErr := write(ctx, *server) if saveErr != nil && !errors.Is(saveErr, encryption.ErrSealUnconfirmed) { // The stored row is unchanged, so whatever it holds stays. s.releaseSecrets(ctx, server.Name, written, kept) if errors.Is(saveErr, ErrNotFound) { return VirtualNameTakenError(server.Name) } return saveErr } // The new row is committed, so what it replaced is released even if // applying it fails: a later edit only sees the new row. s.releaseSecrets(ctx, server.Name, kept, headerValues(server)) return saveErr } // GetManaged returns one admin-managed server row from the store. Config- // declared servers are not store rows, so they (and a missing store) report // ErrNotFound. func (s *Service) GetManaged(ctx context.Context, name string) (*ManagedServer, error) { if s.store == nil { return nil, ErrNotFound } return s.store.Get(ctx, name) } // Delete removes one admin-managed server, then reconciles. func (s *Service) Delete(ctx context.Context, name string) error { if s.store == nil { return fmt.Errorf("mcp server persistence is unavailable") } if s.IsManaged(name) { return fmt.Errorf("mcp server %q is managed by config/env and is read-only", name) } if err := s.remove(ctx, name); err != nil { return err } defer s.notifyChanged(ctx) if err := s.Reload(ctx); err != nil { return fmt.Errorf("mcp server %q was deleted but the running set was not updated: %w", name, err) } return nil } // remove deletes the stored row name and releases the secrets it held. func (s *Service) remove(ctx context.Context, name string) error { s.mutateMu.Lock() defer s.mutateMu.Unlock() previous, err := s.store.Get(ctx, name) if err != nil && !errors.Is(err, ErrNotFound) { return err } if err := s.store.Delete(ctx, name); err != nil { return err } s.releaseSecrets(ctx, name, headerValues(previous), nil) s.forgetOAuth(ctx, name) return nil } // Reconnect force-redials one server and returns its fresh state. func (s *Service) Reconnect(ctx context.Context, name string) (ServerView, error) { view, err := s.manager.Reconnect(ctx, name) view.OAuth = s.oauth.view(view.Spec) return view, err } // Close stops background work, terminates upstream sessions, and cancels // downstream HTTP exchanges. Streamable HTTP clients keep a GET request open // for server events, so those request contexts must be ended before the HTTP // server can complete its graceful drain. func (s *Service) Close() { s.stopOnce.Do(func() { close(s.stop) s.requestMu.Lock() s.closing = true cancels := make([]context.CancelFunc, 0, len(s.requestCancels)) for _, cancel := range s.requestCancels { cancels = append(cancels, cancel) } s.requestMu.Unlock() for _, cancel := range cancels { cancel() } // Wait out an in-flight Reload, then stop later ones, before closing // the upstreams, so none is added after this point. s.stopCancel() s.reloadMu.Lock() s.reloadStopped = true s.reloadMu.Unlock() s.closeResumedSessions() s.manager.Close() }) } // ServeHTTP handles one downstream MCP HTTP exchange. pinnedServer is the // /mcp/{server} path segment ("" for the aggregated endpoint): a server slug // or a virtual server name; a server wins over a virtual server. Gateway // authentication has already run; this layer enforces session-to-principal // binding and stamps the internal identity headers tool handlers read. func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request, pinnedServer string) error { // Rebinding check first: a rejected request must not touch a session, an // upstream, or the request registry. if err := s.origins.check(r); err != nil { return err } if rejectUnsupportedProtocolVersion(w, r) { return nil } requestCtx, cancel := context.WithCancel(r.Context()) s.requestMu.Lock() if s.closing { s.requestMu.Unlock() cancel() return fmt.Errorf("MCP gateway is shutting down") } requestID := s.nextRequestID s.nextRequestID++ s.requestCancels[requestID] = cancel s.requestMu.Unlock() defer func() { cancel() s.requestMu.Lock() delete(s.requestCancels, requestID) s.requestMu.Unlock() }() r = r.WithContext(requestCtx) userPath := core.UserPathFromContext(r.Context()) authKeyID := core.GetAuthKeyID(r.Context()) virtual := "" if s.servedVirtual(pinnedServer) { virtual = pinnedServer } else if pinnedServer != "" { s.closeStaleVirtualSessions(r, pinnedServer) view, ok := s.findVisibleServer(pinnedServer, userPath) if !ok { return core.NewNotFoundError("unknown MCP server or virtual server: " + pinnedServer) } if !view.Spec.Enabled { return core.NewNotFoundError("mcp server is disabled: " + pinnedServer) } } bindingEndpoint := pinnedServer if virtual != "" { bindingEndpoint = virtualBindingPrefix + virtual } var resumed *resumedSession sessionID := strings.TrimSpace(r.Header.Get("Mcp-Session-Id")) if sessionID != "" { admission, host := s.admitSession(r.Context(), sessionID, authKeyID, userPath, bindingEndpoint, r.Method == http.MethodDelete) switch admission { case sessionRejected: // A different principal presented this session ID, or the session // was closed on another replica. Report the session as gone (404 // per the transport spec) so the legitimate client's session stays // unaffected and this caller re-initializes. return core.NewNotFoundError("unknown MCP session") case sessionEnded: w.WriteHeader(http.StatusNoContent) return nil } resumed = host } // The labels header is internal: never trust an inbound value. r.Header.Del(labelsHeader) if labels := core.RequestLabelsFromContext(r.Context()); len(labels) > 0 { r.Header.Set(labelsHeader, strings.Join(labels, ",")) } r.Header.Del(authKeyIDHeader) if authKeyID != "" { r.Header.Set(authKeyIDHeader, authKeyID) } ctx := r.Context() if virtual != "" { ctx = context.WithValue(ctx, virtualServerKey{}, virtual) } else { ctx = context.WithValue(ctx, pinnedServerKey{}, pinnedServer) } r = r.WithContext(ctx) if sessionID != "" && r.Method == http.MethodPost { defer s.trackPOST(sessionID)() } if resumed != nil { resumed.ServeHTTP(w, r) return nil } s.handler.ServeHTTP(w, r) return nil } // requestScope captures the visibility inputs of one downstream session. type requestScope struct { authKeyID string userPath string pinned string // virtual names the virtual server whose members bound the view. virtual string include map[string]struct{} discovery bool } // endpoint is the /mcp/{name} segment the scope was opened on. func (scope requestScope) endpoint() string { if scope.virtual != "" { return scope.virtual } return scope.pinned } // bindingEndpoint is the endpoint identity a session is bound to; see // sessionBinding.endpoint. func (scope requestScope) bindingEndpoint() string { if scope.virtual != "" { return virtualBindingPrefix + scope.virtual } return scope.pinned } func (s *Service) scopeFromRequest(r *http.Request) requestScope { scope := requestScope{ authKeyID: core.GetAuthKeyID(r.Context()), userPath: core.UserPathFromContext(r.Context()), } if pinned, ok := r.Context().Value(pinnedServerKey{}).(string); ok { scope.pinned = pinned } defaultDiscovery := s.searchDiscovery if virtual, ok := r.Context().Value(virtualServerKey{}).(string); ok { scope.virtual = virtual defaultDiscovery = s.virtualDiscovery(virtual) } scope.discovery = discoveryMode(r, defaultDiscovery) if raw := r.Header.Get(ScopeHeader); raw != "" { scope.include = make(map[string]struct{}) for name := range strings.SplitSeq(raw, ",") { if trimmed := strings.TrimSpace(name); trimmed != "" { scope.include[trimmed] = struct{}{} } } } return scope } // getServer builds the per-session MCP server for one downstream client: the // SDK calls it when a new session initializes. The tool/prompt/resource view // is filtered to what this principal may see — clients never discover tools // they cannot call — and stays a snapshot while this replica hosts the // session. A replica that resumes the session builds it from the catalog as // it is then. func (s *Service) getServer(r *http.Request) *mcp.Server { server, _ := s.newSessionServer(s.scopeFromRequest(r)) return server } // newSessionServer builds the server for one session scope and returns it // with the session's bare tool aliases. A new session binds itself when the // SDK asks the server for its ID; the caller binds a resumed one. func (s *Service) newSessionServer(scope requestScope) (*mcp.Server, map[string]string) { views := s.visibleServers(scope) // Assigned below; the SDK asks for the session ID only after getServer // returns, so the binding sees the final map. var aliases map[string]string // Declared first so GetSessionID can bind the session to the server that // hosts it; deleting a virtual server closes its sessions through it. var server *mcp.Server server = mcp.NewServer(&mcp.Implementation{ Name: "gomodel", Title: "GoModel MCP Gateway", Version: version.Version, }, &mcp.ServerOptions{ Instructions: s.composeInstructions(scope, views), SupportedProtocolVersions: sessionProtocolVersions, // Advertise the full feature set even when the visible catalog is // currently empty, so list calls return empty lists instead of // method-not-supported errors. Capabilities: &mcp.ServerCapabilities{ Tools: &mcp.ToolCapabilities{}, Prompts: &mcp.PromptCapabilities{}, Resources: &mcp.ResourceCapabilities{}, }, GetSessionID: func() string { id := rand.Text() s.bindSession(id, scope.authKeyID, scope.userPath, scope.bindingEndpoint(), scope.discovery, aliases, server) return id }, }) endpoint := "/mcp" if name := scope.endpoint(); name != "" { endpoint = "/mcp/" + name } prefixNames := scope.pinned == "" var index *toolIndex if scope.discovery { index = &toolIndex{} } toolOwners := make(map[string]string) promptOwners := make(map[string]string) resourceOwners := make(map[string]string) for _, view := range views { snapshot, _ := s.upstreamCatalog(view.Spec.Name) if snapshot == nil { continue } s.registerTools(server, view.Spec.Name, snapshot, prefixNames, endpoint, toolOwners, index) s.registerPrompts(server, view.Spec.Name, snapshot, prefixNames, promptOwners) s.registerResources(server, view.Spec.Name, snapshot, resourceOwners) } if prefixNames { aliases = bareToolAliases(toolOwners) } if index != nil { index.aliases = aliases s.registerDiscoveryTools(server, index, endpoint) } else if len(aliases) > 0 { server.AddReceivingMiddleware(bareToolCallMiddleware(aliases)) } if s.shared != nil { server.AddReceivingMiddleware(s.shareOnInitialize(scope)) } return server, aliases } // bareToolAliases maps each unambiguous bare tool name to its namespaced // name, so tools/call on the aggregated endpoint accepts a unique bare name // (Postel, per the gateway spec). A bare name claimed by several servers, or // one that collides with a registered namespaced name, gets no alias — the // caller must use the namespaced form. func bareToolAliases(owners map[string]string) map[string]string { counts := make(map[string]int, len(owners)) aliases := make(map[string]string, len(owners)) for exposed, owner := range owners { bare := strings.TrimPrefix(exposed, owner+namespaceSeparator) counts[bare]++ aliases[bare] = exposed } for bare := range aliases { if counts[bare] > 1 { delete(aliases, bare) continue } if _, taken := owners[bare]; taken { delete(aliases, bare) } } return aliases } // bareToolCallMiddleware rewrites a bare tools/call name to its namespaced // registration before dispatch. Exact namespaced names never reach the alias // map (they are excluded at build time), so registered names always win. func bareToolCallMiddleware(aliases map[string]string) mcp.Middleware { return func(next mcp.MethodHandler) mcp.MethodHandler { return func(ctx context.Context, method string, req mcp.Request) (mcp.Result, error) { if method == "tools/call" { if call, ok := req.(*mcp.CallToolRequest); ok && call.Params != nil { if exposed, ok := aliases[call.Params.Name]; ok { call.Params.Name = exposed } } } return next(ctx, method, req) } } } func (s *Service) upstreamCatalog(name string) (*catalog, ServerStatus) { u, ok := s.manager.get(name) if !ok { return nil, StatusDisabled } return u.snapshot() } // visibleServers filters the upstream set down to one request's view: // enabled, allowed for the user path, and inside the pin, virtual server, // and header scopes. A virtual server only narrows the view. func (s *Service) visibleServers(scope requestScope) []ServerView { var virtualMembers []string if scope.virtual != "" { spec, _ := s.virtualSpec(scope.virtual) virtualMembers = spec.Servers } views := s.manager.Views() visible := make([]ServerView, 0, len(views)) for _, view := range views { if !view.Spec.Enabled { continue } if scope.pinned != "" && view.Spec.Name != scope.pinned { continue } if scope.virtual != "" && !slices.Contains(virtualMembers, view.Spec.Name) { continue } if scope.include != nil { if _, ok := scope.include[view.Spec.Name]; !ok { continue } } if !view.Spec.visibleTo(scope.userPath) { continue } visible = append(visible, view) } return visible } func (s *Service) findVisibleServer(name, userPath string) (ServerView, bool) { for _, view := range s.manager.Views() { if view.Spec.Name != name { continue } if !view.Spec.visibleTo(userPath) { return ServerView{}, false } return view, true } return ServerView{}, false } // composeInstructions merges upstream instructions into the gateway's own, // so guidance written for an upstream server survives aggregation. func (s *Service) composeInstructions(scope requestScope, views []ServerView) string { var b strings.Builder if scope.pinned == "" { names := make([]string, 0, len(views)) for _, view := range views { names = append(names, view.Spec.Name) } sort.Strings(names) if scope.virtual != "" { fmt.Fprintf(&b, "GoModel MCP virtual server %q", scope.virtual) } else { b.WriteString("GoModel MCP gateway") } fmt.Fprintf(&b, " aggregating %d server(s): %s. Tools and prompts are namespaced as {server}%s{name}.", len(names), strings.Join(names, ", "), namespaceSeparator) if scope.virtual != "" && len(names) == 0 { b.WriteString(" None of its member servers is available for this API key.") } } if scope.discovery { if b.Len() > 0 { b.WriteString(" ") } fmt.Fprintf(&b, "Tools are not listed directly: find them with %s and run them with %s.", searchToolsName, callToolName) } for _, view := range views { snapshot, _ := s.upstreamCatalog(view.Spec.Name) if snapshot == nil || strings.TrimSpace(snapshot.instructions) == "" { continue } if b.Len() > 0 { displayName := strings.TrimSpace(view.Spec.DisplayName) if displayName == "" || displayName == view.Spec.Name { fmt.Fprintf(&b, "\n\n## %s\n", view.Spec.Name) } else { fmt.Fprintf(&b, "\n\n## %s (`%s`)\n", displayName, view.Spec.Name) } } b.WriteString(strings.TrimSpace(snapshot.instructions)) } return b.String() } // registerTools adds one upstream's tools to a session server. Tool metadata // and valid schemas relay verbatim; only the name is prefixed on the // aggregated endpoint. Arguments relay as raw JSON — validation belongs to // the upstream. A non-nil index collects the tools for search discovery // instead of listing them. func (s *Service) registerTools(server *mcp.Server, upstreamName string, snapshot *catalog, prefix bool, endpoint string, owners map[string]string, index *toolIndex) { for _, tool := range snapshot.tools { exposed := tool.Name if prefix { exposed = NamespacedName(upstreamName, tool.Name) } if owner, taken := owners[exposed]; taken { slog.Warn("mcp tool name collision; keeping first server", "tool", exposed, "kept", owner, "skipped", upstreamName) continue } owners[exposed] = upstreamName if index != nil { index.add(exposed, upstreamName, tool) continue } clone := *tool clone.Name = exposed server.AddTool(&clone, s.toolHandler(upstreamName, tool.Name, exposed, endpoint)) } } func (s *Service) toolHandler(upstreamName, toolName, exposedName, endpoint string) mcp.ToolHandler { return func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) { if err := s.authorizeSession(req.Session, upstreamName); err != nil { return nil, err } started := time.Now() result, err := s.manager.CallTool(ctx, upstreamName, toolName, req.Params.Arguments) s.recordToolCall(req, upstreamName, exposedName, endpoint, started, result, err) if err != nil { return nil, fmt.Errorf("mcp server %q: %w", upstreamName, err) } return result, nil } } // registerPrompts mirrors registerTools for prompts. func (s *Service) registerPrompts(server *mcp.Server, upstreamName string, snapshot *catalog, prefix bool, owners map[string]string) { for _, prompt := range snapshot.prompts { exposed := prompt.Name if prefix { exposed = NamespacedName(upstreamName, prompt.Name) } if owner, taken := owners[exposed]; taken { slog.Warn("mcp prompt name collision; keeping first server", "prompt", exposed, "kept", owner, "skipped", upstreamName) continue } owners[exposed] = upstreamName clone := *prompt clone.Name = exposed originalName := prompt.Name server.AddPrompt(&clone, func(ctx context.Context, req *mcp.GetPromptRequest) (*mcp.GetPromptResult, error) { if err := s.authorizeSession(req.Session, upstreamName); err != nil { return nil, err } params := *req.Params params.Name = originalName result, err := s.manager.GetPrompt(ctx, upstreamName, ¶ms) if err != nil { return nil, fmt.Errorf("mcp server %q: %w", upstreamName, err) } return result, nil }) } } // registerResources adds resources and resource templates. Resource URIs are // globally meaningful, so a URI already claimed by an earlier server is // skipped with a warning rather than silently re-routed. func (s *Service) registerResources(server *mcp.Server, upstreamName string, snapshot *catalog, owners map[string]string) { read := func(ctx context.Context, req *mcp.ReadResourceRequest) (*mcp.ReadResourceResult, error) { if err := s.authorizeSession(req.Session, upstreamName); err != nil { return nil, err } result, err := s.manager.ReadResource(ctx, upstreamName, req.Params) if err != nil { return nil, fmt.Errorf("mcp server %q: %w", upstreamName, err) } return result, nil } for _, resource := range snapshot.resources { if owner, taken := owners[resource.URI]; taken { slog.Warn("mcp resource URI collision; keeping first server", "uri", resource.URI, "kept", owner, "skipped", upstreamName) continue } owners[resource.URI] = upstreamName server.AddResource(resource, read) } for _, template := range snapshot.templates { key := "template:" + template.URITemplate if owner, taken := owners[key]; taken { slog.Warn("mcp resource template collision; keeping first server", "template", template.URITemplate, "kept", owner, "skipped", upstreamName) continue } owners[key] = upstreamName server.AddResourceTemplate(template, read) } } // authorizeSession re-checks, on every call, that the user path a session // was bound to may still use upstreamName. Sessions snapshot their catalog at // initialize, so without this a narrowed user_paths or a new // disallowed_user_paths entry would not reach sessions that are already open. // A session without a binding is rejected: bindings live as long as the // session, so a missing one means it was deleted or expired mid-call, and // guessing a user path could slip past disallowed_user_paths. A session on a // virtual server is also held to the virtual server's current members, so // removing a member or the virtual server reaches open sessions too. func (s *Service) authorizeSession(session *mcp.ServerSession, upstreamName string) error { sessionID := "" if session != nil { sessionID = session.ID() } return s.authorizeSessionID(sessionID, upstreamName) } func (s *Service) authorizeSessionID(sessionID, upstreamName string) error { s.bindMu.Lock() binding, ok := s.bindings[sessionID] s.bindMu.Unlock() if !ok { return fmt.Errorf("mcp server %q: %w", upstreamName, ErrServerNotVisible) } if _, visible := s.findVisibleServer(upstreamName, binding.userPath); !visible { return fmt.Errorf("mcp server %q: %w", upstreamName, ErrServerNotVisible) } if virtual, ok := strings.CutPrefix(binding.endpoint, virtualBindingPrefix); ok && !s.virtualServes(virtual, upstreamName) { return fmt.Errorf("mcp server %q: %w", upstreamName, ErrServerNotVisible) } return nil } // bindSession records the principal a new session was initialized under. func (s *Service) bindSession(sessionID, authKeyID, userPath, endpoint string, discovery bool, toolAliases map[string]string, server *mcp.Server) { now := s.now() s.bindMu.Lock() s.bindings[sessionID] = sessionBinding{ authKeyID: authKeyID, userPath: userPath, endpoint: endpoint, lastSeen: now, virtualIncarnation: s.endpointIncarnation(endpoint), discovery: discovery, toolAliases: toolAliases, server: server, } s.bindMu.Unlock() } // closeStaleVirtualSessions closes the sessions of a virtual server that is no // longer served when one of them sends a request. DeleteVirtual closes the // sessions it finds, but one still initializing during the delete registers // afterwards; this catches it on its next request instead of at the idle // timeout. func (s *Service) closeStaleVirtualSessions(r *http.Request, name string) { sessionID := strings.TrimSpace(r.Header.Get("Mcp-Session-Id")) if sessionID == "" { return } endpoint := virtualBindingPrefix + name s.bindMu.Lock() binding, ok := s.bindings[sessionID] s.bindMu.Unlock() if ok && binding.endpoint == endpoint { s.closeEndpointSessions(endpoint) } } // closeEndpointSessions ends every session bound to endpoint (see // sessionBinding.endpoint) and drops their bindings, so an endpoint that no // longer exists does not keep idle sessions until the idle timeout. Closing // waits for in-flight requests to drain, so it runs in the background. func (s *Service) closeEndpointSessions(endpoint string) { s.closeMatchingSessions(func(binding sessionBinding) bool { return binding.endpoint == endpoint }) } // closeIncarnationSessions ends the sessions bound to one incarnation of a // virtual server endpoint, and not those of a server recreated under its name. func (s *Service) closeIncarnationSessions(endpoint, incarnation string) { s.closeMatchingSessions(func(binding sessionBinding) bool { return binding.endpoint == endpoint && binding.virtualIncarnation == incarnation }) } func (s *Service) closeMatchingSessions(match func(sessionBinding) bool) { var servers []*mcp.Server s.bindMu.Lock() for id, binding := range s.bindings { if !match(binding) { continue } delete(s.bindings, id) servers = append(servers, binding.server) } s.bindMu.Unlock() for _, server := range servers { closeSessions(server) } } // ToolCallLabel names the tool a session's tools/call runs, matching its // usage entry. name is the called tool and target the call's // arguments.name. In a discovery session call_tool resolves to its target, // and a bare name the session accepts resolves to its namespaced name. Only // the session knows whether call_tool is the meta-tool: on a pinned endpoint // without discovery it can be an upstream tool of that name. func (s *Service) ToolCallLabel(sessionID, name, target string) string { s.bindMu.Lock() defer s.bindMu.Unlock() binding := s.bindings[sessionID] if binding.discovery && name == callToolName && target != "" { name = target } if exposed, ok := binding.toolAliases[name]; ok { return exposed } return name } // liveBinding returns the binding of a session this instance still hosts and // refreshes its last-seen time. The binding of an SDK session that already // ended (the idle timeout) is dropped, so the shared store can resume it. func (s *Service) liveBinding(sessionID string) (sessionBinding, bool) { s.bindMu.Lock() defer s.bindMu.Unlock() binding, ok := s.bindings[sessionID] if !ok { return sessionBinding{}, false } if binding.resumed == nil && binding.server != nil && !hasSessions(binding.server) { delete(s.bindings, sessionID) return sessionBinding{}, false } binding.lastSeen = s.now() s.bindings[sessionID] = binding return binding, true } func hasSessions(server *mcp.Server) bool { for range server.Sessions() { return true } return false } // sweepBindings evicts bindings whose sessions idled out, tracking the SDK's // own session timeout. func (s *Service) sweepBindings() { ticker := time.NewTicker(sessionIdleTimeout / 2) defer ticker.Stop() for { select { case <-s.stop: return case <-ticker.C: s.evictIdleBindings() } } } // evictIdleBindings drops the bindings of sessions idle past the idle // timeout. A session with a POST in flight is not idle: its timeout is // paused until the POST ends. func (s *Service) evictIdleBindings() { cutoff := s.now().Add(-sessionIdleTimeout - time.Minute) s.bindMu.Lock() defer s.bindMu.Unlock() for id, binding := range s.bindings { if binding.posts == 0 && binding.lastSeen.Before(cutoff) { delete(s.bindings, id) } } }