When a Teammate Leaves, You Should Not Have to Rotate the Whole MCP URL
Shared stdio MCP configs look convenient until someone on the team leaves. The right model is per-teammate credentials, not one shared URL everyone pastes into Cursor.
The first MCP server my team stood up, we handed out one URL. It went into Slack. It went into three people's Cursor configs. It went into a shared .cursor/mcp.json checked into the repo. It had no auth. It worked for everyone, immediately, and that was the problem.
Two months later, one of those people took a new job. Nobody remembered that the MCP URL was in their Cursor config. Nobody remembered that the URL was in a GitHub Gist they had pasted during an onboarding session. The URL kept working. The agent kept running, on a laptop we no longer controlled, against our internal billing API.
We got lucky. The laptop was wiped and returned. But the moment of panic was enough to redesign how we issue MCP access.
One shared URL is a shared credential
When you hand a single MCP URL to the whole team, you have effectively issued one credential with N bearers. There is no audit trail, no per-user rate limit, and no way to revoke access for one person without revoking it for everyone. That is not a feature of MCP. It is a side effect of running a plain HTTP endpoint with no auth layer in front of it.
The instinct is to bolt on a single shared API key. That solves the empty-URL problem and almost nothing else: the key is now in the same Slack channel, the same Gist, the same three Cursor configs. When someone leaves, you rotate the key and re-paste it everywhere, and within a day two people have lost their MCP tools because they did not see the Slack message.
The model you actually want
We landed on four rules, and they are boring on purpose:
- One URL, N keys. The public MCP endpoint is stable. The credential that proves who is calling is per-teammate. Revoking one key leaves everyone else untouched.
- Keys live in the client config, not in the repo. Each developer's
~/.cursor/mcp.jsonhas their own key. Nothing shared is checked in. Onboarding means issue a key, not forward the Slack thread. - You can see who is calling what. The gateway logs which key invoked which operation, when. You do not need this for every call, but you want it the first time someone asks "did the staging incident touch production?"
- Rotation is a one-button action, not a re-onboarding. If a key leaks, you revoke it, issue a replacement, and tell that one person to update their config. The URL they paste stays the same.
What this costs you
The objection is always the same: this sounds like IAM, and IAM is painful. Fair. But the alternative is not "no IAM"; the alternative is "IAM where the revoke step takes an hour and accidentally locks out the team."
The minimum viable version is smaller than it sounds. You do not need roles, groups, SCIM, or SSO on day one. You need:
- A way to issue a key and stamp it with a human-readable label (
alice@, staging-only). - A way to revoke that key without touching the URL.
- A log line that says which label made which call.
Everything else (scopes, expiry, per-key rate limits) is a phase two upgrade that you can layer on once you have the primitive. Trying to design the full permission model before anyone is calling your MCP server is how you end up back at "one shared URL in Slack."
The staging/prod split is the second win
Once you have per-teammate keys, the next question is trivial: which key can reach which upstream? The same OpenAPI doc describes staging and prod operations. You do not want the customer-success agent, which is running against a read-only sandbox in support, to be able to call the production refund endpoint because they happen to have the shared key.
The cheapest version is two environments with two key pools. The "staging" pool can call everything. The "support" pool can call a small allowlist of read operations. When support needs a destructive path turned on, you flip it for one key, not for the whole team.
This is also where the spec earns its keep. The set of operations a key is allowed to call should be derived from the same OpenAPI doc you already uploaded, not from a second hand-maintained list. Otherwise you have rebuilt the drift problem in a different shape.
The failure mode we did not predict
The surprise was how often keys get rotated not because someone left, but because they were pasted somewhere they should not have been. A key ends up in a screenshot shared in a bug report. A key ends up in a transcript pasted into an LLM support chat. A key ends up in a shell history someone shares during a pairing session.
When that happens, the question is not "how did this leak?" It is "how fast can I kill this one key and move on?" If the answer is "pivot to the infra team, open a ticket, wait for the on-call," the team will start shrugging and keeping the old key around. Per-teammate keys make the fix cheap enough that people actually do it.
The mental model
Treat the MCP URL like a phone number and the per-teammate key like a SIM. The number stays the same; you can replace the SIM when a phone is lost, when a line is compromised, or when a person leaves. You would not hand the same SIM to every teammate and call it "team phone."
If your current setup has one MCP URL with one shared key going into every developer's client, do the math tonight: how long would it take you to revoke access for one person tomorrow, without disrupting everyone else? If the answer involves a Slack broadcast, that is the thing to fix first.
We built per-teammate key issuance and revocation into Powerduck around exactly this moment — upload the OpenAPI doc, issue a key per teammate, revoke one without touching the URL — because the first version of our own tool had the same Slack-shared-URL problem.

