Skip to content
Chapter 2 of 7 · Consuming serversLocal and hosted server builds

15 min read

Local and hosted server builds

On this page

A vendor's MCP server frequently exists in two forms: a package you launch yourself, and an HTTPS endpoint they operate. An MCP server is the program that advertises a list of tools to a model and performs them on request, and when both forms carry the same name in a registry it is natural to treat them as one dependency in two locations. They are not. The self-hosted package is a process your shell starts — a Node or Python binary on the machine running the agent — holding whatever credential is in its environment, speaking to code you downloaded. The hosted form is a multi-tenant HTTPS service — one deployment serving every customer of the vendor, yours included — which they deploy on their own schedule, reached across intermediaries neither party controls.

That difference does not announce itself, because the configuration change between the two is small enough to review in four minutes: a URL instead of a command, a secret reference instead of an environment variable, and possibly a timeout. Nothing in that diff is wrong, and the deployment is still a change of server.

Four things move at once

It helps to be specific about which properties changed, because they fail at different times and in different ways.

The tool list and capability set are the first, and the least expected, because engineers carry an intuition from ordinary libraries: the same version means the same API. A hosted MCP endpoint is not a copy of the packaged build. Vendors commonly ship the self-hostable package with the subset of functionality that works without their control plane, and reserve the rest for the hosted service, or the reverse — hold new tools back from the hosted endpoint until they are stable enough for every tenant. Recording initialize and tools/list against each, using the recorder from chapter 1, where a listed server is connected to once and what it serves is written down, turns this from a theory into two files you can diff:

bash
$ diff <(jq -S . snapshot.local.json) <(jq -S . snapshot.prod.json)
< "protocolVersion": "2025-03-26",
> "protocolVersion": "2025-06-18",
<   "capabilities": { "tools": { "listChanged": true }, "logging": {} },
>   "capabilities": { "tools": { "listChanged": true }, "resources": { "subscribe": false } },
<   "serverInfo": { "name": "linear-mcp", "version": "0.4.0" },
>   "serverInfo": { "name": "linear-mcp-cloud", "version": "2026.02.3" },
< "linear_delete_issue",
> "linear_list_projects",
> "linear_get_project",
> "linear_update_project",

Eleven tools locally, thirteen in the hosted build, and the sets are not nested — the hosted build adds three project tools and drops linear_delete_issue entirely, which is why local runs never exercise the code path that handles it being absent. The version strings do not even share a scheme, so 0.4.0 and 2026.02.3 cannot be compared, ordered, or used to reason about which is newer. That is worth noticing early, because it removes an option you might otherwise have planned on when pinning versions in chapter 3.

The arithmetic in that diff closes, and it is worth doing rather than trusting, because doing it is what proves the two tool sets are not merely different sizes. Start from the eleven the local build advertised. The hosted build drops linear_delete_issue, which leaves ten. It adds linear_list_projects, linear_get_project and linear_update_project, which brings it back to thirteen — exactly the number the hosted snapshot reports, with no fourth change hiding in the part of the diff that was elided. That both a subtraction and an addition appear is the whole of the claim that the sets are not nested. A drop on its own would make the hosted list a subset of the local one; an addition on its own would make the local list a subset of the hosted one; one of each means neither list contains the other. What that licenses is the uncomfortable conclusion: a client written against either environment has code paths the other environment will never exercise, in both directions, and no amount of local testing reaches them.

The belief that keeps this invisible is worth naming, because it is a good belief nearly everywhere else. In an ordinary library a version number is a promise one author makes about one artefact, so two systems reporting the same version really are running the same code, and the whole practice of dependency management rests on that. It fails here for a structural reason rather than a careless one: the two forms of the server are two artefacts, built from one repository at the publisher's discretion, and nothing obliges them to expose the same surface. The version strings are the evidence rather than the exception — 0.4.0 and 2026.02.3 are not two points on one scale, and a publisher who intended them to be comparable would have used one scheme.

Credentials are the second. Locally a key sits in the process environment, is read once at startup, and never expires; the child process either has it or crashes immediately. In production the same authority arrives as an Authorization header on every request, minted by a token endpoint, valid for a bounded window, and revocable by someone who is not you.

Sequence: Agent → MCP client (run starts (t=0s)); MCP client → Token issuer (client_credentials grant); Token issuer → MCP client (access_token, expires_in=3600); MCP client → Hosted server (initialize + tools/list); Hosted server → MCP client (13 tools, session id); MCP client → Hosted server (tools/call linear_create_issue (t=3612s)); Hosted server → MCP client (401 invalid_token); MCP client → Token issuer (refresh); Token issuer → MCP client (new access_token); MCP client → Hosted server (retry with new token); Hosted server → MCP client (400 unknown session id).

The last two exchanges are the part local development cannot show you. Refreshing a token is routine and every SDK handles it; what surprises people is that the hosted server also kept per-session state keyed to a session identifier established at initialize, and some implementations bind that session to the token that created it. A refreshed token can therefore leave you authenticated and sessionless at the same time, which surfaces as a 400 or 404 on the next call rather than as anything mentioning authentication. The fix is not subtle — re-initialize on session loss and replay nothing — but it is code that must exist, and it will never be exercised by a stdio setup where the session lives and dies with the child process.

The chain there is short enough to check link by link, and every link is a decision somebody made for a defensible reason. MCP is a stateful protocol running over a transport that is not, so a streamable-HTTP server has to associate successive requests with one another; it does that by issuing a session identifier at initialize and expecting it back on every later request. Some implementations then record which token established the session, on the reasonable grounds that a session should not outlive the authority that opened it. Refreshing rotates the token, so the credential presented on the next call is no longer the credential the session was recorded against. The server is therefore holding a valid token and a session it will not honour, and it reports the half that failed, which is the session — and that is why the error names a session identifier and mentions authentication nowhere. Nothing in the sequence is a defect in either party. It is two correct behaviours meeting, and a stdio child never produces it because there is no session to lose and no token to rotate.

The credential also changes in scope, not only in mechanism. A personal API key carries whatever the developer's account can do, which is usually more than the agent needs and occasionally everything; a machine identity, meaning an account that belongs to the service rather than to any person, is granted explicitly, and the grant is where you discover that the hosted server exposes a scope named something like projects:write that nobody requested. Two consequences follow. The first is that a tool present in the prod snapshot may still fail at call time with an authorization error, because the tool list reflects what the server implements rather than what your token may invoke, and the two are separate gates. The second is that audit entries stop naming a human, which is the outcome you want, and means that a misbehaving agent now needs its own revocation path rather than someone deleting a personal token they can no longer find.

Failure modes are the third. A crashed local server is a clean signal: the process exits, the pipe closes, the SDK raises, and it happens in under a millisecond of the fault. Hosted failures are slower and more ambiguous, and the latencies below are quoted as percentiles measured over a week of calls, where p50 is the median call and p99 the slowest one call in a hundred.

PropertyLocal stdio childHosted HTTPS endpoint
Typical call latency2–4 ms p50140 ms p50, 2.1 s p99 across a region boundary
How unavailability showsProcess exit, EPIPE, immediate504 at the proxy's 30 s ceiling, or a silent stream drop
Partial failureEffectively noneSome calls succeed while others time out
Rate limitingNone429 with Retry-After, commonly 60 requests/min/tenant
Long streamsNo intermediaryIdle SSE connection cut by a proxy at 60 s
ConcurrencyOne process, one agentShared tenant quota across every agent instance
Blast radius of a bad deployYour machineEvery consumer of that endpoint at once

The right-hand column contains a failure with no local analogue at all: a load balancer closing an idle server-sent-events (SSE) stream — the long-lived HTTP response a streamable-HTTP transport holds open so the server can push messages back — after 60 seconds, during a run where the model spends longer than that reasoning between two tool calls. It surfaces as MCP error -32000: Connection closed, the negative number being a JSON-RPC error code, the numbering MCP inherits from the request/response format its messages are written in. Locally the same gap costs nothing, because a pipe to a child process has no idle timeout and no intermediary with an opinion about it.

Whether that failure can happen at all is decided by a single subtraction, and it is worth performing in advance rather than during an incident. The intermediary cuts an idle stream at sixty seconds. The longest gap the agent can produce between tool calls is however long the model spends reasoning, which in the run described later in this chapter is seventy-four seconds. Seventy-four is larger than sixty, so the stream is cut every time that reasoning step occurs, and the agent's failure rate is simply the rate at which the model thinks for longer than the ceiling. None of it is intermittent in the sense of being random; it is a threshold, and which side of it a run lands on is decided by a quantity nobody in the review had a figure for, because on a pipe to a child process it had never mattered.

Environment-specific configuration is the fourth, and it is the one people believe they have handled because they parameterised the URL. The network path is part of the configuration: egress allowlists, the outbound-traffic rules that have to name the vendor's hostname before anything in the VPC — the private network segment your workloads run in — may reach it, a corporate proxy that may or may not forward Authorization on redirect, DNS that resolves the vendor's anycast address to a different region from the one your latency budget assumed. Timeouts, retry counts and connection pool sizes are configuration too, and the values that were invisible locally are load-bearing here.

Set those four against the nine-line pull request that carries them and the review's failure becomes arithmetic rather than carelessness. The URL replacing a command is the network path. The secret reference replacing an environment variable is the credential mechanism. The raised timeout is the failure-mode column, acknowledged in the one place it is cheapest to acknowledge. Three properties, and between them every line in the diff — the columns sum to the whole change, with no line unaccounted for. The fourth property, the tool list and the capability set, contributes no line at all, because none of it lives in your configuration; it is decided by whichever build answers, and which build answers is the vendor's decision. So a reviewer reading nine lines is reading a complete and accurate account of three properties and a silence about the fourth, and a silence is not distinguishable from a property that did not move.

Per-environment verification

Connect from inside that environment, with that environment's credential, and record the snapshot; connecting from your laptop with a production token proves nothing about the network path. Confirm the token's lifetime and what the client does when it expires mid-run, by minting a short-lived token deliberately and letting it lapse during a call. Establish the rate limit and whether it is per tenant or per credential, because three agent replicas sharing one machine identity share one 60-requests-per-minute bucket. Find the longest idle gap your agent can produce between tool calls and compare it against the intermediary's idle timeout, not the vendor's documented one. Check which tools your prompts and bindings reference and confirm each exists in that environment's snapshot, including the ones you only call in error paths.

Capability differences deserve their own line on that list, because they change behaviour without producing an error. A hosted snapshot advertising a resources capability the local build lacks means the model's context in production contains resource listings that never appeared locally. Nothing fails; the model simply has more material and uses it, and material named in a way that makes a record look readable can produce an answer referring to something the corresponding tool call is still refused by scope. New capability in an environment is a behaviour change even when no call errors, which is the argument for diffing the capability object rather than only the tool names.

Parity is not available, and staging will not give it to you

The instinct at this point is to reach for environment parity — run the hosted build locally, or point staging at something equivalent — and it is worth saying plainly that this is not available for a vendor-hosted server. There is no artefact of the hosted build to run. Pointing staging at the vendor's sandbox endpoint gets you a third server with its own tool list and its own deploy cadence, so a green staging run is evidence about the sandbox and only weak evidence about production. Teams that come from a twelve-factor background find this uncomfortable, because backing services in that model are supposed to be attachable resources that differ only in credentials, and a vendor's MCP endpoint quietly violates that: it differs in interface.

Twelve-factor is an industry precedent here rather than a metaphor: it is a real methodology real teams organise deployments around, and the expectation it sets — a backing service is an attached resource, swapped by changing a URL and a credential — is a large part of why the nine-line diff read as complete. Where it stops transferring is the assumption underneath it. The methodology was written for services whose interface is fixed by a protocol somebody else standardised, so a Postgres in staging and a Postgres in production genuinely do differ only in the connection string. A vendor's MCP endpoint has no interface fixed by anyone outside the vendor: the tool list is the interface, the vendor authors it, and they ship it to the two forms of the server independently. Carrying the twelve-factor expectation across that boundary is the mechanism by which the difference goes unnoticed, so the useful move is to keep the methodology and stop counting a hosted MCP endpoint among its backing services.

What replaces parity is the recorded contract per environment, and treating any code path that depends on a tool present in one and absent in another as a branch you have to handle rather than a difference you can ignore. The build a team developed against is evidence about that build, and the snapshot is what turns the evidence into something a reviewer can compare rather than something a person remembers.

None of this is about transport mechanics; topic 6.3, on transports, covers how stdio framing and streamable HTTP work, and none of what fails here is a defect in either. The failures come from the properties that ride along with a transport choice — expiry, intermediaries, shared tenancy, an independently deployed build — rather than from the framing itself. Nor is any of it solved by the vendor being competent. A well-run hosted service still has a different tool list from its packaged sibling, still expires tokens, and still sits behind somebody's proxy.

One dependency with two forms is therefore two dependencies to verify, and the unit of verification is the environment rather than the server. The tool list, the capability set, the credential mechanism, the failure modes and the network path each have to be established from inside the environment that will use them, with that environment's credential, on that environment's path — which converts a class of incident that arrives as an unexplained mid-afternoon failure into a file somebody can read on a Tuesday.

Recording what production serves gives you a true statement about one moment. Nothing in it holds the server still — the vendor deploys on their cadence, and the packaged build you launch yourself re-resolves whatever @latest means the next time a container starts. The next chapter is about removing that second source of movement, which is the half you control.

Consuming servers — read the current version at https://daviddominguez.dev/learn/en/consuming-servers/local-and-hosted-server-builds

Back to top