Self-hosted MCP servers vs managed: the real cost
Self-hosted MCP servers vs managed comes down to four recurring costs: credential storage, refresh, per-user auth and audit. Here is where each lands, and when self-hosting still wins.
Self-hosted MCP servers vs managed: the four costs that recur
An MCP server is easy to stand up and expensive to keep. The first one takes an afternoon: pull a server for your ticketing system, give it a token, run it somewhere the team can reach, point Cursor at it. Nothing about that afternoon is wrong. The self-hosted MCP servers vs managed question does not arrive until the ninth server, when four of them hold a long-lived token in an environment variable, two broke the week an OAuth app rotated, and nobody can answer which of two connected workspaces an agent wrote to last Tuesday.
This applies specifically to servers speaking the Model Context Protocol's Streamable HTTP transport (the transport introduced in the 2025-03-26 revision of the MCP spec, which replaced the earlier HTTP+SSE transport). Architectural claims below are current as of that revision; MCP's auth and transport model has changed twice in a year and may change again.
Four costs recur no matter which servers you run, and they recur in this order. Each one becomes visible only after the previous one is handled:
- Credential storage. Where the secret sits, what encrypts it, and what a read-back returns.
- Refresh. What happens when a token expires overnight, and what the caller sees when it does.
- Per-user auth. Whether the server knows who is calling, or whether every call is the service account.
- Audit. Whether there is one record per attempt, and whether that record names the account actually reached.
A served control plane is an answer to those four. It is not an answer to everything, and the case for keeping a server yourself is further down, stated with actual numbers rather than as a rhetorical concession.
Three shapes managed MCP already comes in
Before comparing self-hosted to managed, it's worth naming that "managed MCP" is not one architecture. Roughly three patterns exist in the wild, and they have different failure modes:
- Proxy in front of servers you already run. The vendor adds auth and logging at a gateway layer, but the underlying server code (and its bugs, its rate limits, its schema) is still yours. Governance can only ever be as good as what the proxy can see, which is usually the outbound HTTP call and nothing about intent.
- Registry reseller. The vendor hosts a catalog of community-maintained MCP servers and lets you deploy them with one click. Convenient for breadth, but the vendor did not write the connector, so enforcement at the operation level (see below) is either absent or bolted on after the fact, matched against a schema the vendor doesn't control.
- First-party connector author. The vendor writes and maintains the connector code itself, which means restrictions can be pinned against the actual upstream operation rather than against a tool name or an HTTP path the vendor is guessing at.
Elaichi is the third pattern: it is the MCP server, not a proxy in front of MCP servers you already run, and it authors the connectors it serves rather than reselling a registry of servers other people maintain. That distinction matters for the enforcement claims in this post. A proxy or a registry reseller cannot make the same guarantees about what a rule actually binds to, because they don't own the connector code.
Where the credential sits when you are the one holding it
In Elaichi, connector credentials are not in Elaichi. One Cloudflare Durable Object per organization holds members, teams, roles, ACLs (access control lists), connections, toolboxes, restrictions, grants and the audit trail. Per-account secrets live in a separate credential service, encrypted at rest with AES-256-GCM. The store that answers "may this person call this tool" is deliberately not the store that holds the token. A compromise of one does not automatically compromise the other.
That split changes what a read-back can return. Reading an account's configuration gives you the public values plus secret_paths, the list of dot-paths that were encrypted, carrying none of their values. That list is the only thing that decides whether a variable is a secret. Editing one is refused, and the refusal text is identical whichever side produced it, so a caller cannot infer which side refused. A small detail, but it's the difference between a system that leaks its own topology through error messages and one that doesn't.
Two controls sit on top. An organization can supply its own OAuth app per connector, where the accepted body is client_id, client_secret and scopes, and anything endpoint-shaped is deliberately unrepresentable (you cannot redirect the OAuth flow to a different host through this field). That capability is gated on connector:manage rather than connection:manage, so everyone who can delete a connection does not silently gain the ability to repoint the organization's OAuth app. Separately, per-organization envelope encryption can use a customer-managed key in AWS KMS.
Self-hosting the equivalent means you own all of it: the encryption, the key rotation, the rule about what a configuration read is allowed to echo back. If the server needs exactly one credential and you already run a secret manager, that's a few hours of wiring. At nine servers and thirty accounts, it's a product with its own backlog.
Refresh is a state machine, and its failure needs a name
The credential service owns refresh. When a refresh fails, the connection is marked needs_reauth rather than failing silently. That state exists because the alternative is worse than an outage: a server that keeps calling with a dead token returns errors shaped like permission problems, and a model reads a permission problem as an instruction to try a different tool or a different approach. You get a confident wrong answer instead of a connection someone can fix. The failure mode shifts from "obviously broken" to "silently wrong," which is strictly worse for anything an agent is doing unsupervised.
Write refresh handling yourself and the list is longer than it looks. At minimum:
- Detect expiry before the call rather than after, which means tracking token TTLs, not just handling 401s reactively.
- Handle refresh-token rotation, where the vendor invalidates the old refresh token on every use (Google and Microsoft both do this; a naive retry with the old token creates a race condition).
- Distinguish "token expired" from "app revoked on the vendor's side". The second one will keep failing no matter how many times you refresh, and needs a different UI state and a different remediation path.
- Stop calling once the state is known-bad, rather than retrying into a rate limit.
- Surface the state to the person who owns the connection, somewhere they will actually see it, like a Slack DM or dashboard badge, not a log line.
Each of these is a small piece of code in isolation. Together, across every connector you add, they're an on-call rotation for a system whose failures look like the model being wrong rather than the infrastructure being wrong, which makes them harder to triage, not easier.
Per-user auth, or the service account you promised to replace later
Most self-hosted MCP servers start with one shared credential, because per-user OAuth is the expensive part: it requires a consent screen, a token store keyed by user rather than by server, and a decision about what happens when that user leaves. A shared credential works, and it means the audit answer to "who did this" is "the server did it", which is not an answer a security review accepts once more than a couple of people share the token.
Elaichi is one organization-wide endpoint: POST /mcp, standard MCP over Streamable HTTP, JSON-RPC 2.0, stateless, behind OAuth. The client sends the user to a consent screen, the user signs in, and the grant is tied to that person. There are no per-toolbox URLs and no embedded tokens. There is no MCP server to mint, list or revoke per user. The address is fixed and the grant is what varies, which is why revocation is a database write and not a search for a URL somebody pasted into a client.
The timing is worth stating exactly, because it's the fact most often written wrong. removing or suspending a member revokes every live grant in the same transaction as the membership change For revocation, removal and suspension, the next call is refused, whichever client made it. Role changes and restriction changes are different: allow about two minutes, on MCP, console and REST alike. Treat these two categories separately when you're reasoning about how fast an access change takes effect, because they are not the same code path.
Identity comes from the same place your other systems do. SAML and OIDC SSO are built in-house, with SCIM v2 for users and groups and group-to-role mapping, alongside verified-domain auto-join, single-use invite links and just-in-time SSO provisioning.
Enforcement at four points, not at the edge of one server
A self-hosted server typically enforces once, at execution, because that's where the code naturally lives. The request comes in, you check a permission, you make the call. Elaichi runs the same resolver at every point the request touches: browse, connect, advertise and execute, plus a final check on the fully substituted outbound URL (after templated variables are filled in, in case a variable smuggles in a different destination). Advertise matters more than it sounds. A tool the model never sees in its tool list is a tool it never argues with, retries against, or works around.
The rules themselves are narrow on purpose. restriction targets are role or user only; the organization default is the absence of a rule, which means allow everything. Precedence runs user override, then role rule, then that default, and a user-targeted rule replaces role rules entirely rather than layering on top of them. Within the winning layer, allow rules union, block rules union, and blocks always beat allows. Note that the allowlist stage engages on the presence of an allow rule, not its contents, so an allow rule that names nothing denies everything. That is the strictest rule the system can express, and a real way to lock a team out by accident if you write an empty allow rule expecting it to be a no-op.
A rule is written against a connector and a tool, but the canonical resource and method are pinned against the catalog at write time. A block matches the tool name or the pinned operation; an allow matches the pinned operation only. The reason: a tool's advertised name can be changed by whoever edits the connector's documentation, which makes the name a token the governed party controls. Governance has to bind the operation, not the label, or a rename silently defeats a block rule.
Frozen parameters cover the arguments rather than the tool. Frozen keys are stripped from the advertised schema, so the model never sees them and can't attempt to override them; frozen values are merged over caller arguments at execution, so passing the key in a call cannot un-freeze it. Precedence is entry defaults, then caller or model arguments, then frozen parameters. Frozen always wins last.
One limitation belongs here rather than in a footnote. The prompt-injection write gate in the Elaichi agent window does not apply to POST /mcp, and structurally cannot, because an MCP server receiving a JSON-RPC call never sees the user's original natural-language prompt, only the arguments a client already decided to send. What does hold on the endpoint is RBAC (role-based access control) per operation, a forbidden classification that no OAuth scope can reach regardless of what the token is authorized for, output redaction, scope limits and full audit logging. If your threat model depends on catching injected instructions at the point of tool call, MCP-level enforcement alone will not do that. It has to happen upstream, in the client or agent runtime.
What the audit trail has to survive
Audit events and application logs share one record shape, which is what lets a single query answer "what happened" instead of correlating two systems by eye. actor_kind is a field rather than an inference, and its values include user, system, staff, scim, api_token and ai_assistant, so whether an action was taken by an AI is recorded at the point of action and not guessed later from a user agent string or an IP range.
one entry per tool-call attempt, succeeded or failed Argument names and counts are logged; argument values never are. This is a deliberate tradeoff (you can see that a write_file call took 3 arguments, not what was written) that favors not logging secrets over completeness. Error text is firewalled: the string returned to the caller is derived from the third party's response body and is never written to the audit trail, because audit records are organization-visible, readable by the in-product assistant and fanned out to whatever destination the customer configures. An upstream error message could itself contain sensitive data, and the audit pipeline is designed not to become a second exfiltration path for it. Each organization has its own log tenant, enforced in the type system rather than by a WHERE clause, because a dropped WHERE clause leaks data across tenants silently while a wrong tenant reference at the type level fails loudly and returns nothing.
Two honest caveats, stated plainly rather than buried: export forwards to a customer's own destination, where Datadog is implemented today and Splunk HEC and Microsoft Sentinel are accepted in the schema but not yet delivering. If your SIEM is Sentinel, that pipe is not live yet. And organization deletion tears down the workspace without a path to purge that organization's log tenant, which the product reports by returning the residue by name rather than claiming the data is gone. If your compliance posture requires a guaranteed purge on deletion, confirm this directly before you rely on it.
Side by side on the four recurring costs
| Cost | You run the server | Served control plane |
|---|---|---|
| Credential storage | Your secret manager, your key rotation, your rule about what a config read echoes back | Separate credential service, AES-256-GCM at rest, read-back returns secret_paths with no values, optional customer-managed key in AWS KMS |
| Refresh | You detect expiry, handle rotation, notice vendor-side revocation, and surface the state | Credential service owns refresh; a failed refresh marks the connection needs_reauth |
| Per-user auth | Usually one shared credential, so every call is the service account | One organization-wide POST /mcp behind OAuth, no per-toolbox URLs and no embedded tokens |
| Audit | A schema you design, and usually one you change twice as requirements surface | One entry per tool-call attempt naming the account actually reached, per-organization log tenant, export to your own destination |
| Enforcement point | Execution, because that's where the code is | All four resolver points, plus a check on the substituted outbound URL (see body) |
The case for keeping an internal, proprietary server yourself
Self-host when all of the following are true:
- The tool is internal and proprietary. It talks to a system only you run, so there's no third-party OAuth app and nothing to refresh against a vendor's rotation schedule.
- The blast radius is one team's own data, and usually read-only.
- Identity is already settled by the network (VPN, internal DNS) or by the internal service it fronts, so you're not rebuilding SSO.
- A handful of engineers use it from one client, not a dozen people across three clients.
Under those conditions, a rough build estimate looks like this: a single-credential server with no per-user auth and no refresh logic (because it's an internal system with a long-lived static token) is a day, maybe two, including the deploy target and code review. Add refresh handling for a token that actually expires, and you're adding 1-2 days for the state machine described above. Add per-user OAuth because more than a few people need distinguishable access, and that's another 2-4 days plus an ongoing cost every time the OAuth provider changes its rotation behavior. Add an audit schema a compliance reviewer would accept, and you're designing a table now that you'll alter at least once after the first internal audit asks a question it can't answer.
That server does not need a control plane. It needs a deploy target and a code review. Adding a governance layer at that scale buys a second system and no new answer, and the per-seat cost of a managed plane is real money against a risk that's already bounded. The same logic holds earlier than most teams expect: if you have one connected application and two people, a gateway of any kind, self-built or vendor, is premature.
If the internal system is really an HTTP API rather than bespoke MCP logic, there's a middle path: a custom connector authored from JSON config, which brings it under the same restriction resolver and audit pipeline described above rather than requiring you to run a separate server binary. Custom connectors can be forked from a public connector and pull upstream changes through a review surface that separates new tools, safe updates, config diffs, conflicts and upstream removals, with conflicts and destructive removals unchecked by default rather than auto-applied. connector:create is flagged high-trust for the obvious reason: a custom connector can be pointed at any destination, so granting that permission is closer to granting outbound network access than to granting a UI setting.
Where the cost lands on each side
The managed side is a line item. Gold is $15 per user per month, or $120 per user per year (a ~33% discount for annual billing) per the pricing page. 14-day trial, no credit card to start. Checkout does collect a card, and it sets the paid trial to the remaining days rather than granting a fresh 14, so it is one continuous trial rather than two Billable seats are active memberships with a minimum of one, and suspended members plus the free-seat roles (Guest, Billing Admin and Auditor) are excluded, so a compliance reviewer reads the audit trail without consuming a license. If a trial ends without checkout, the workspace pauses: plan-gated features lock and gated routes return a structured error, and nothing is deleted.
At scale, the arithmetic is straightforward and worth doing before you decide: 10 seats is $1,200-1,800/year depending on billing cadence; 50 seats is $6,000-9,000/year; 200 seats is $24,000-36,000/year. Compare that against the self-hosted build-and-maintain cost above, multiplied by however many connectors you're actually running. The crossover point in most teams' experience is somewhere between the third and the tenth connector, once per-user auth and audit become non-negotiable rather than nice-to-have.
The self-hosted side is headcount, and not the part you estimate up front. The initial build for one server is genuinely a day or two. The recurring cost is the token-refresh edge cases, the per-user OAuth you deferred, the audit schema you change twice, and the on-call rotation for a system whose failures look like the model being wrong.
Residency cuts both ways. Hard residency covers eu and us only, backed by a Durable Object jurisdiction, so compute and storage stay put in either region. Organizations choose one of the three at creation; apac is a placement hint only, best-effort. If your requirement is a specific jurisdiction outside the EU or US, self-hosting gives you a control that a hint does not.
Decide on blast radius, not on taste. One internal server against a system you own: keep it. Third-party accounts, more than one person, more than one AI client, and a question a regulator might ask about who called what: that's when the four costs stop being a side project and start being infrastructure that needs an owner.
Further reading: what changes when a per-user URL is the credential · pricing details · how keys, regions and session controls work · team-by-team starting points · 1Password connector, a useful test case since it's a credential-bearing account that exercises all four costs above.