feat: hosted MCP servers write their own hosting state
Part of AIM-668. Expand half of the hosting ownership flip; stacked on #8009. The contract half (3B) follows.
Summary
-
Hosted MCP servers (a toolset's canonical MCP server, same id as the toolset) and their endpoint are now the write path for hosting state.
mcpServers.update/deletewrite the server.mcpEndpoints.updatewrites the address and keeps the server slug equal to it.- Create, clone and Platform MCP create make the server plus one platform endpoint directly.
- Assistant attach re-enables the server.
- Platform MCP
set_mcp_network_accesswrites the server's mode.
-
Every one of those writes also mirrors the state onto the toolset's hosting columns in the same transaction, through one helper,
hostingstate.MirrorToToolset. It mirrors:mcp_enabled, andmcp_is_public(the old mapping, keeping the flag while disabled);- the issuer;
mcp_slug/custom_domain_idfrom the endpoint: the toolset's current address while an endpoint still holds it, else the platform endpoint, else the oldest. A server with no endpoint leaves the address as it is; the mirror never invents one;- name and tool variations group.
- The org issuer migration moves wrapped toolsets' issuer column with their servers again, and audits each hosted server it moves. The demo seed writes consistent columns.
-
A hosted server keeps a single address in this step:
mcpEndpoints.deleteon a hosted server is rejected, and update edits the address in place.mcpEndpoints.createadds an address only to a hosted server with none, which is how a server recovers after its custom domain is deleted. A hosted server with no live address cannot be enabled.- Moving an endpoint to or from a hosted server is rejected.
- The dashboard's endpoints table hides add and remove for hosted servers, and offers add when a hosted server has no address. It moves a hosted address onto the verified custom domain in place, and back to the Speakeasy domain once that domain can no longer serve.
-
A disabled hosted server reads back with the toolset's public flag, as before, so a public server with external OAuth can be re-enabled from the dashboard.
-
Serving reads the server's own state, and the drift deny is gone. The mirror keeps both copies equal, so old pods (which still deny drift) and new pods answer alike. And when 3B stops mirroring, pods running this PR don't 404 servers edited on 3B pods.
-
The same goes for the non-serving readers that #8008 made fail closed on drift through
hostedmcp.LoadServerState(gateway members, Kit binding, plugins, export, mint, attach preview). They now read the server's state instead of treating a drifted pair as disabled and private. The reason is the same: with the mirror there is no drift (0 drifted pairs on the prod replica), and reading the server keeps those paths consistent with serving and with 3B pods during its rollout. -
Hosting writes lock the toolset row first, then domains, endpoints and the server.
-
From the earlier work (unchanged):
toolsets.updaterejects the hosting fields with a typed error;toolsets.setUserSessionIssuerandtoolsets.checkMCPSlugAvailabilityare removed. To detach a session issuer, use the newmcpServers.updatefieldclear_user_session_issuer, which works for every server type. Setting it together withuser_session_issuer_idreturns a 400. It is refused while identity-chaining bindings depend on the issuer, is audited, and resyncs remote sessions. On hosted servers the toolset column is cleared too. Remote and tunneled servers now mint an issuer on save only when the save moves them onto a new remote or tunneled backend;- the toolset read fields come from the server;
- the backfill tool only creates missing servers.
-
Tests:
hostedmcptest.RequireMirroredchecks every write path above. After each write it asserts:- the toolset columns equal the mirror of the server and its endpoint;
- the server's visibility and issuer match what an older pod's drift check expects;
- the pre-flip toolset-to-server sync (
hostedmcp.BackfillSync), run in a transaction, writes nothing and adds no audit rows.
-
Behaviour change: a disabled hosted server re-enabled by an assistant attach now comes back private, unless it has external OAuth or an OAuth proxy, in which case it comes back public. A disabled server reads back the same way. It used to restore the toolset's public flag. 3B stops copying that flag, so a stale value could otherwise make a server public again after a user set it private.
-
On endpoint updates, the server slug follows the address only while the server slug equals the address being replaced and the toolset's alias. If another server holds that name, the write is refused with a 409 conflict, because #8009 pods would otherwise fail every toolset save on that server. Endpoints on deleted domains no longer block private network validation.
Motivation
The flip needs zero downtime. Moving write ownership to the MCP server in one step would leave old pods, which still read the toolset columns and run the old sync, disagreeing with new pods during a deploy or after a revert. So the change splits into expand (this PR) and contract (3B):
- This PR moves the writers and keeps both copies equal.
- 3B stops mirroring and allows several endpoints per hosted server.
Rollout and revert
- Old pods stay consistent. Old pods (#8009's code) read the toolset columns, check that the server matches them, and run the old toolset-to-server sync on toolset saves.
- Every new write keeps the columns equal to the server and its single endpoint, so an old pod's drift check passes and serves the same state.
- The old sync finds nothing to change, so it writes and audits nothing. The tests prove this for every write path.
- Revert to #8009 is clean. The columns and servers this PR writes are exactly what #8009's code would write itself.
- Merge gate: merge only after #8009 has deployed, and after the
toolsets:deprecated-mcp-hosting-apiwarning has shown no caller other than the dashboard for 7 days. External callers of the rejected fields or removed methods get errors after this ships. - Checked on the prod replica:
- 1143 live hosted servers, 0 drifted from their toolset columns.
- 0 have more than one live endpoint, and 0 have a single endpoint that differs from the toolset's address. The rule that keeps the toolset's current address is a guard with no matching rows.
- 9 have no live endpoint, all on soft-deleted custom domains. Their toolset address is left as is, an old pod's sync writes nothing for them (0 rows would get an address recreated), and they recover by adding one.
- A toolset whose platform address has no endpoint can only come from legacy rows; no write in this PR creates that state. An old pod's sync recreates the platform endpoint for such a row, which is #8009's own behaviour and restores the address.
- Wrapperless toolsets with a slug: 0 in live projects; 52 in soft-deleted projects, left as is.
š¤ Generated with Claude Code
Summary by cubic
Hosted MCP servers now write their own hosting state, and the toolset's hosting columns mirror it in the same transaction. This keeps older pods that still read the columns and run the toolset-to-server sync on the same state during rollout; the follow-up stops the mirroring.
toolsets.updaterejectsmcp_enabled,mcp_slug,mcp_is_public,network_access_mode, andcustom_domain_id, andsetUserSessionIssuerandcheckMCPSlugAvailabilityare removed; usemcpServers.updateandmcpEndpoints.update.- A hosted server keeps a single address: delete is rejected, create adds one only when none (how a server recovers after its custom domain is deleted), and moves edit the endpoint in place, keeping its id.
- A hosted server without an address cannot be enabled, so attaching a toolset whose server can't be enabled is rejected; a disabled server reads back as public only when it carries external OAuth or an OAuth proxy, so it can be re-enabled.
- On endpoint moves, the server slug follows only its own address, refusing a rename onto another server's slug, and endpoints on soft-deleted domains skip private network ingress checks.
- The dashboard edits a hosted server's address in the shared endpoints table; the old hosted-only address sections and their block component are gone.
- Serving reads the server's state directly and no longer denies drifted rows; it re-reads a hosted server after auth, so one disabled or changed access meanwhile stops serving.
- The external MCP migration reads the server's state for member enablement, and the backfill tool only creates missing servers; existing canonical servers are left untouched.
š Preview Environment (PR #8016)
Preview URL: https://pr-8016.dev.getgram.ai
| Component | Status | Details | Updated (UTC) |
|---|---|---|---|
| ā Database | Ready | Existing database reused | 2026-10-26 22:11:10. |
| ā Images | Available | Container images ready | 2026-10-26 22:14:18. |
Gram Preview Bot