Skip to content

Gateway session sync

Platform-api owns session persistence and the agent directory in Postgres. Runtime-api writes through its authenticated control-plane client. Gateways read only their MCPServer generation and hold no database credentials or shared internal token. The storage/sync foundation is tracked in #718; migration #659 wires the runtime management and gateway authorization paths onto it.

Audiences and routes

Listener Authentication Routes
Platform control plane, 8080 Shared INTERNAL_AUTH_TOKEN, trusted services only POST/GET /internal/access/sessions, GET/PATCH/DELETE /internal/access/sessions/{namespace}/{name}, POST /internal/access/sessions/revoke
Gateway sync, 8085 Projected Pod-bound service-account token, audience mcp-runtime-gateway GET /internal/gateway/v1/sessions, GET .../changes?epoch=...&after=..., GET .../{namespace}/{name}

The OpenAPI path groups declare x-audience. Gateway routes are mounted only on their own listener, and the service's sync port has no ingress route. The platform NetworkPolicy permits tenant gateway pods only on 8085; 8080 accepts platform peers and the ingress controller. Operator gateway egress and projected token mounting are delivered by the migration PR.

The control listener also provides PATCH/DELETE /internal/access/sessions/{namespace}/{name}/enrollment. Completion checks the immutable session UID, expiry, active agent and current revocation under the session-state lock and removes only pending metadata. Cleanup additionally checks the owner and that enrollment is still pending before deleting and publishing its tombstone. Neither operation accepts replacement authorization fields. Runtime-api uses these mutations after signing or failed enrollment; using a full session upsert here would undo concurrent revocation. Ingress-controller access on 8080 matches the bundled Traefik's app: traefik label and Helm/k3s Traefik's app.kubernetes.io/name: traefik label, including controllers outside the traefik namespace. This permits public API routing and registry forward-auth; neither Traefik selector permits 8085. Session sync still requires a managed gateway pod and the Pod-bound token checks below.

The operator uses http://mcp-platform-api.mcp-platform.svc.cluster.local:8085 by default. Set MCP_GATEWAY_SESSION_SYNC_URL on the operator to use a different trusted internal endpoint, and configure matching network policy for that deployment. It must expose the dedicated gateway audience, never the shared control-plane API. Tokens are mounted only in the gateway container.

TokenReview must confirm the requested audience and return the bound Pod name and UID. Platform-api then reads the live Pod, ReplicaSet, Deployment, and MCPServer controller references and checks every UID. A shared tenant service account username, Pod label, or caller's query field cannot select another server. Deleted or recreated resources and retiring servers fail authentication. See Kubernetes' bound token and TokenReview documentation. Platform-api needs cluster-wide get on those four resource kinds because gateways run in dynamically created namespaces, plus create TokenReview. It receives no tenant write, list/watch, or tenant Secret permissions for sync. The namespace's Kubernetes workload administrator remains a trusted actor: controller references are not an isolation boundary against someone who can mutate the actual server workload or its tokens.

Consistency and failure behavior

Session identity is the composite namespace/name; the binding name remains local to its namespace. The record retains MCPServer UID, immutable session UID and creation time, grant linkage, subject and authority teams, labels, annotations, and revocation timestamp/cause. Reusing a server name with a new UID cannot reuse the old sessions. Bulk retirement with cause server_retired also persists a UID fence: delayed issuance and unrevocation for that old UID are rejected. Agent deactivation and its session revocations commit together; issuance validates and locks the agent in the same transaction. Management supports human-only and team-only sessions too; directory validation applies only when an agent ID is present. Deletion can supply ?uid=<session UID> to reject a stale retirement snapshot rather than delete a replacement session. Deleted session names are permanently reserved within their namespace. Create a new name or re-enroll the adapter after deletion; reusing the deleted name returns 409 Conflict, even for a republished server with a different UID. Certificates identify namespace/name, so allowing that name to identify a new row would let an old certificate inherit the new binding. Identity reservations are retained separately from the bounded change log and must be included in database backups. Existing rows can still be updated or explicitly unrevoked when their agent and server remain active.

A snapshot contains all active, unexpired sessions for the authenticated server and one epoch/sequence cursor. Snapshot and changes use a repeatable-read SQL transaction. Mutations lock the singleton sync-state row before allocating sequences and retain that lock until commit, so a committed higher sequence cannot hide a still-uncommitted lower revocation. Changes carry kind: session and either an upsert or a delete tombstone. The change query returns at most 1,000 entries; more: true requires draining additional pages immediately before counting the cache as caught up. Polling every 500 ms targets roughly one-second revocation propagation, subject to database and network latency.

The most recent 100,000 change events are retained globally; lagging gateways receive 410 Gone and relist. An epoch mismatch, negative cursor, or cursor ahead of the current sequence also forces a relist. Gateways deny before the first successful snapshot, serve last-known-good for at most ten minutes after a successful catch-up, and deny once stale. A get-on-miss result must be scoped to the same server UID and must not advance the global freshness timestamp. Authorization remains in process and still enforces expiry, revocation, grants, side effects, and trust restrictions.

Validated policy reloads compile immutable tool-name and subject-grant indexes before atomically activating the new document. Legacy embedded session bindings also have ID/subject indexes; current platform-synchronized sessions continue to use the separate server-scoped session cache. A failed load or compilation keeps the previous complete document and its indexes. Indexes do not change policy JSON, revision digests or schema versions. Expiry, revocation and monitor/enforcing decisions are evaluated on every request; they are not cached decisions. See the policy benchmark results for the bounded VM comparison and its limits.

A known epoch/cursor reset or an explicit 401/403 identity rejection immediately invalidates cached authority. It cannot keep serving the old snapshot while a relist fails. SESSION_SYNC_STALE_WINDOW can shorten the ten-minute maximum for network/store outages. /config/status and mcp_gateway_session_sync_age_seconds report freshness; -1 on the metric means no successful sync yet. Policy publication rejects grant/tool documents larger than 700 KiB and increments mcp_runtime_gateway_policy_oversize_total.

Roll out matching platform-api, runtime-api, operator and gateway images. Re-deploy servers through the supported CLI so gateway token mounts and namespace egress rules are refreshed. Old Kubernetes sessions are not imported; adapters re-enroll through the platform. This is a policy schema v4 change and requires a new minor release while the project is 0.x.

Every platform-api process start rotates the shared database epoch. Ordinary restarts therefore cause a safe additional snapshot; concurrent replicas also observe the shared epoch and converge after startup. After restoring Postgres, restart platform-api before reconnecting gateways. This rotates the epoch even if a restore has the same numerical sequence. Restoring a database while leaving the platform process running cannot be detected reliably from a numerical cursor alone. The cursor-ahead check additionally detects a restore that lowers the sequence.

The platform API ingress policy preserves port 8080 for Traefik, platform namespace callers, and the analytics API in mcp-observability (which uses it for authentication). Analytics is selected by both namespace and pod labels; that exception does not grant access to session-sync port 8085.

Revocation E2E measures from the completed management write and requires an HTTP 401 session_revoked response within one second. Each probe can use only the remaining overall budget; shorter per-request timeouts must not discard a valid observation. Failures include the last curl exit code and HTTP status.

Verification

Run platform-api and runtime client tests with an empty kubeconfig:

export KUBECONFIG=$(mktemp)
cd services/platform-api
go test -race -count=1 ./...
cd ../runtime-api
go test -race -count=1 ./internal/platformclient

The platform persistence tests cover snapshots with 3,001 sessions, retention and restore fences, tombstones, pagination, retirement fences, directory membership, and rollback when agent revocation fails. Authentication tests cover audience/port separation, missing bound-Pod identity, live controller generations, and denial of cross-server gets. Route ownership and manifest tests verify the gateway group is absent from the public listener and ingress paths.

For actual SQL and issuance/deactivation concurrency, point PLATFORM_SESSION_TEST_DSN explicitly at a disposable Postgres database and run:

export KUBECONFIG=$(mktemp)
cd services/platform-api
go test -race -count=1 ./internal/platformstore -run TestPostgresSessionSyncLifecycle

The test creates and removes its own schema; it never falls back to the deployment's POSTGRES_DSN. Migration E2E must exercise CLI session creation and revocation with real MCP traffic, check the policy ConfigMap revision stays unchanged, measure revoke-to-deny delay, and test cold/warm/stale behavior. Use the explicit Kind/staging target guards from the contributor runbooks; never point these suites at production.

After retiring and republishing a server, prove the old certificate receives the exact 401 session_not_found denial through the recreated ingress. A transient Traefik 404 is route convergence, not an authorization decision. The certificate initialize helper retries it only when its bounded route-startup option is explicitly enabled; persistent 404s time out and accepting the retired certificate fails immediately. Tool invocations are never retried for readiness.