A common MCP setup carries auth the same way: create an API key, paste it into mcp.json or an .env file, restart the client. It works. Now the key sits in plaintext on every machine that runs the agent. It often carries one broad, fixed permission set. Every agent that reads the file gets the same set. And when one agent misbehaves, the fix is rotate the shared key everywhere.
There's a second failure that arrives after you add real auth: the agent calls a tool and gets a bare 403. The user doesn't know what to approve. The agent doesn't know what to ask for. Somebody ends up reading server logs.
I build multiuser AI systems for production. My agents act on users' Gmail and Slack accounts every day - external agents like Claude Code included. None of those agents receives a provider token. This is the auth chain that makes that work, including the part that took the most design: what happens when consent is missing at call time.
A URL instead of a key
An external agent doesn't get the Gmail or Slack credential. It gets a URL - a managed MCP endpoint my platform exposes. I call that endpoint the door, and so does the interface further down.
Claude Code connects to the door as an OAuth client. Dynamic client registration (DCR) registers its client identity against a configured redirect allowlist. The user signs in and approves the maximum this connection may be granted. The OAuth exchange returns a scoped KDCube bearer tied to that client and grant, not a provider token.
The approval screen also resolves those requested capabilities to the accounts behind them. If a required provider is not connected, it is named there with a connect link. The connect step already says what to add - the same shape as the call-time denial later in this post, moved to the front.
Approve it, and the connection becomes a card.
That card is the whole governance relationship. Nobody registered Claude by hand, and nobody pasted a provider token. The checklist is a ceiling: the most this app may be granted here.
Its vocabulary follows the realm. Slack is a single-provider realm, so it uses Slack claims such as slack:search. Mail can span providers, so the door uses mail:read and mail:send, then the account broker resolves those to provider-specific claims such as gmail:read and gmail:send on the selected account.
The binding decides, not the account
A ceiling is not access. That is what the ACCOUNTS section of the same card decides: which accounts, and which permissions on each.
Two Google accounts, each with its own gmail:read / gmail:send ticks. The user can bind this agent read-only on one account and read+send on another. If an account can send mail but the agent isn't bound to send on it, the call is refused. When another account can satisfy it, the denial returns labeled account candidates instead of silently choosing one.
And the default is closed. Untouched means nothing: an agent with no ticks on a provider has no access to any of its accounts, however capable the accounts are and whatever the connection ceiling says. On a first connect nothing is pre-checked - the picker shows the accounts, and the user decides.
Everything on the card stays editable: narrow, extend, revoke. Revocation applies on the next call. There is no provider credential copied into every agent to rotate. The client's scoped KDCube bearer has its own expiry and revocation.
An authorized request reaches trusted tool code with the resolved user, caller identity, and required claim - not with a provider token in its arguments. At the trusted boundary, the account broker resolves and refreshes the user's connected credential for that provider call. The provider adapter uses it for the call and does not persist it.
The provider credential never appears in the model context, prompt, generated code, tool result, or delegated client's bearer. Nothing on those sides of the fence can disclose a credential it never received.
And nothing rests on what was true at connect time. Every call re-crosses two gates: does this caller hold a grant for this operation, and does a connected account authorize this claim with this caller bound to use it there. The connect-time approval is only the ceiling. The per-call checks are the authority.
Two recipes walk this end to end: how a tool resolves the credential at the trusted boundary, and the full chain, three ways in.
A denial names its own fix
Agents grow. A tool starts needing a claim it didn't need last month. A user connects a second Slack workspace. An operation gets called for the first time mid-conversation. In a token world each of those is a mystery failure.
Here a denial is not only a reason. It is a small recovery protocol, and each gate returns a different one.
Gate 1: this caller lacks an operation grant. The service names the exact operation and missing claims, then points to the right grant-extension path:
Gate 2: the caller is admitted, but the connected-account side cannot satisfy the operation. Once gate 1 passes, this contract names the account-side condition and the action that can resolve it:
retry_hint is my favorite field: the error says whether the caller can retry the operation after the user acts. When several accounts match, the platform never picks one silently. account_required returns labeled candidates, and the caller retries with an account_id.
The KDCube chat component can render the denial as an actionable consent banner that opens the matching request in Connection Hub. An external MCP client receives the same structured recovery path in its tool response.
Consent appears when the operation needs it, scoped to what it needs. In an open-ended agent turn, the needed tool and account may emerge only after reasoning starts.
One list, every agent
My in-house agents get the same card - here's one of them:
An external app that OAuthed in and a hosted agent in my runtime are the same kind of citizen: a client identity, a grant, a per-account binding, the same edit and revoke. Connecting an account authorizes no agent by itself. Each agent's access is its own grant - the accounts here are the same two, the binding is not: the account the connected app may send from, this agent may only read. "What can this agent do right now" always has a visible answer.
Why this shape
The MCP specification released on 2026-07-28 removed protocol-managed sessions. A server that needs continuity now mints an explicit handle and validates it when it comes back. Akamai's assessment is blunt: the protocol improved its foundation, but critical state and authorization boundaries now depend on how each server is built.
KDCube's managed endpoint speaks MCP 2026-07-28 while continuing to serve earlier clients.
On this managed path, no provider credential enters the prompt, generated code, or delegated client's bearer. The authenticated KDCube grant binds the caller and user; tenant/project comes from the deployment, not client metadata. Every call then rechecks the resource, operation, claim, account, and this caller's per-account binding. That is the answer here to provider-secret leakage, cross-user connected-account access, wrong-account use, and authority that changed after connect time.
My design conclusion is simple: the agent should never hold provider credentials. A trusted layer in front should own auth, scoping, and revocation. This is that layer, built out: per-agent grants, per-account bindings, call-time fences, denials that carry their own fix. The runtime it lives in is KDCube - self-hosted, MIT licensed. The agent side stays simple: it calls tools, and when authority is missing it gets an answer it can act on.
Try to break it
- Make an agent leak a provider token. Prompt-inject it, ask it to dump its prompt, tool arguments, or generated-code environment. On this managed path the provider token is in none of them. An external client holds only its scoped KDCube bearer, which can expire or be revoked separately.
- Use a send-capable account through a read-only binding. The send must be refused with an actionable denial, including labeled candidates when another account can satisfy the request.
- Call an operation whose claim was never granted. You should get the structured denial - exact claim, hub link,
retry_hint- not a 500, and not a silent success. - Register a DCR client with your own server as the redirect. The allowlist refuses it before any consent screen exists.
To try any of it against your own accounts, the two walkthroughs are in the repo: connect an external client to a KDCube service is the path Claude took above, and authenticated MCP from zero is the other side of it - declaring the door, the ceiling, and the redirect fence yourself.
If you get anywhere, open an issue. I'd rather have that than a star.
The design docs in the repo cover each decision in detail; the deeper write-up is Authenticated MCP in KDCube: delegated credentials, not shared.
Next: someone brought me their own agent and a data API for their organization. Two days later it was live at scale on their own landing page - streaming answers you can interrupt, follow-ups while it works, files in and out, their knowledge attached, web search, memories, usage. All theirs.
Third in a series on the parts of the agent stack that aren't the agent. Part 1, on dropping tool calling, is here; Part 2, on runtime economics, is here.







Top comments (2)
The structured denial is much better than a bare 403. The recovery action itself becomes a security boundary, though: Iād bind its nonce to the original caller/client, user, exact operation, requested claims, selected account candidates, and a short expiry, then consume it once. After approval, retry should perform a fresh authorization check rather than treating the consent result as permission to replay the old call. That closes confused-deputy and approval-replay paths where an agent forwards a consent URL or swaps the account/operation between denial and execution. For side-effecting tools, returning a request digest in both the denial and audit record would also let the user verify what the eventual grant was used to authorize.
The denial path being structured instead of just blocked is the useful bit here. Once the recovery request has a nonce, scope, expiry, and an approval trail, the agent can keep working without ever holding the credential itself. That feels like the right split.