feat: hosted MCP servers own their endpoints
Closes AIM-668. Contract half of the hosting ownership flip; stacked on #8016 (3A).
Merge only after 3A is deployed to prod.
Summary
This is the contract half of moving hosted MCP hosting state onto the MCP server. 3A moved every writer onto the hosted MCP server (a toolset's canonical MCP server, same id as the toolset), served hosted servers from their own state, and kept the toolset's hosting columns mirrored. This PR stops the mirroring and lifts the single-address limit.
- A hosted server can have several endpoints.
mcpEndpoints.createanddeletework on hosted servers, under the hosted address rules: free-account prefix, only the verified custom domain, no leaving a live domain.- An enabled hosted server cannot lose its last endpoint that can serve (platform, or a verified and activated domain). Removing one on a deleted or unverified domain is always allowed.
- The dashboard lists every address, platform ones included, and offers add and remove. A private hosted server is offered only addresses in its ingress namespace, and only verified, activated domains are offered for new addresses.
- Removing the last address of an enabled hosted server asks you to disable the server first instead of sending a delete the backend would reject.
- The legacy alias follows its endpoint. A toolset created before the cutover keeps its
/mcp/<slug>alias inmcp_slug/custom_domain_id. After a hosted endpoint create, rename or delete, the alias stays on the endpoint that holds it. If none does, it moves to the platform endpoint, else the oldest one; with no live endpoint it stays put.- This is the same address rule 3A's mirror applies, so 3A and 3B pods agree.
- Renaming an endpoint retires the old URL, as on main and 3A. Deleting it moves the alias to a remaining address.
- Toolsets created on 3B have no alias.
- Visibility and the session issuer are no longer copied onto the toolset. They live only on the server. The org issuer migration no longer moves wrapped toolsets' issuer column, and the demo seed writes NULL/false columns.
- Name and tool variations stay toolset settings.
mcpServers.updatestill writes the toolset's name andtool_variations_group_id, andhostedmcp.Syncmirrors those two from the toolset onto the server. - The server slug no longer follows its endpoint. Look hosted servers up by endpoint slug after an address change.
- A disabled hosted server reads and re-enables private, unless it carries external OAuth or an OAuth proxy, which a private server cannot; then public. The frozen toolset public flag no longer counts, since nothing updates it. One helper (
hostingstate.ReEnableVisibility) drives both the read and assistant re-enable. The dashboard can still re-enable a disabled OAuth server as public. - Usage metrics count wrapped servers by server visibility only.
- Kept from 3A:
- The toolset row is locked first on hosting writes.
- Per-server issuer-migration audit.
- Root reconcile after Platform MCP network-access changes.
- Enabling needs a live address.
Reverting
Reverting 3B to 3A is safe for serving; only multi-endpoint management is lost. An e2e revert check on 3A code alone found:
- Every URL keeps serving, including servers with several endpoints and servers whose toolset columns are NULL. Nothing 404s.
- On a two-endpoint server, 3A allows saves, visibility changes and endpoint renames, but refuses endpoint create and delete (422).
- On a server with a NULL alias, 3A's first write stamps the alias onto an existing endpoint, which already serves.
So a revert keeps everything reachable, but servers that gained a second endpoint cannot add or remove addresses until 3B is back.
Rollout
- Merge gate:
- 3A is deployed to prod.
- The SDK gate is already met: 3A shipped the
toolsets.updaterejections and removedtoolsets.setUserSessionIssuer/toolsets.checkMCPSlugAvailability.
- No serving gap during the rolling deploy: 3A pods serve hosted servers from their own state, with no drift check against the toolset columns, so there is no 404 window.
- 3A pods during the window:
- They reject adding or removing a hosted address ("keeps a single address") until the deploy finishes.
- They rename the server slug to the endpoint slug on endpoint updates.
- Their mirror still writes the toolset columns. That is harmless: 3B ignores those columns except the alias, and the alias rule is the same on both sides, so a 3A write after a 3B rename or delete does not move or strand the alias. On a toolset created on 3B, a 3A write can stamp an alias equal to an existing endpoint; that address already serves, so it changes nothing.
- Known limits:
- A grantee with
mcp:writeon one server only cannot add or remove that server's addresses; that needs project-levelmcp:write. - There is no API to detach a session issuer from a hosted server.
toolsets.addExternalOAuthServerrefuses a disabled server that reads private; make it public first (same in 3A).
- A grantee with
- Checked on the prod replica:
- 1143 live hosted servers: 0 drifted from their toolset columns, and 0 with more than one live endpoint.
- 9 are left with no live endpoint after their custom domain was deleted. They are untouched and can add an address.
- Wrapperless toolsets with a slug: 0 in live projects, and 52 in soft-deleted projects, left as is.
š¤ Generated with Claude Code
Summary by cubic
Closes AIM-668 (and the duplicated AIM-171). Hosted MCP servers now own their hosting state and can have several endpoints. mcpEndpoints.create and delete work on hosted servers under the hosted address rules, and an enabled server can't lose its last servable endpoint. Stops mirroring hosting state onto toolset columns; visibility and the session issuer live only on the server, and toolsets.setUserSessionIssuer stays as a deprecated shim through mcpServers.update until 2026-11-25. The legacy /mcp/<slug> alias follows the endpoint that holds it, and the server slug no longer follows its endpoint. A disabled hosted server reads and re-enables private, unless it carries external OAuth or an OAuth proxy, then public. Hosted endpoint slugs keep the platform length limit on every domain; usage metrics count a wrapped server by its visibility only, so a toolset whose id a server kept after leaving it counts as neither public nor private; command-palette search also matches endpoint slugs. Name and tool variations stay toolset settings, mirrored onto the server.
The Speakeasy host still serves a legacy custom-domain alias, so its slug is now reserved from other projects and the server reports it as legacy_platform_slug. Dashboard settings show that address instead of claiming there is none. The address availability check takes an optional mcp_server_id so a server may claim its own older alias, and alias moves no longer contend on the platform host's slug lock.
Rollout
- Merge only after 3A is deployed to prod.
- No serving gap during the rolling deploy: 3A pods already serve hosted servers from their own state.
- 3A pods during the window reject hosted address create/delete and may rename the server slug to the endpoint slug; their mirror is harmless because the alias rule matches.
Reverting
- Reverting to 3A keeps every URL serving but loses multi-endpoint management; servers with several endpoints can't add or remove addresses until 3B is back.
š Preview Environment (PR #8021)
Preview URL: https://pr-8021.dev.getgram.ai
| Component | Status | Details | Updated (UTC) |
|---|---|---|---|
| ā Database | Ready | Existing database reused | 2026-10-27 22:27:07. |
| ā Images | Available | Container images ready | 2026-10-27 22:30:57. |
Gram Preview Bot