Model Context Protocol Server: Transport and Hosting
A Model Context Protocol server is a process that exposes tools, resources and prompts over one of two transports: standard input and output when it is a child process on the same machine, or an HTTP endpoint when it is hosted. Hosting adds four decisions the protocol leaves to you — which endpoint a caller hits, what survives a restart, who may call it, and what you keep from each call.
Short answer: A Model Context Protocol server is a process that exposes tools, resources and prompts over one of two transports: standard input and output when it is a child process on the same machine, or an HTTP endpoint when it is hosted. Hosting adds four decisions the protocol leaves to you — which endpoint a caller hits, what survives a restart, who may call it, and what you keep from each call.
Key takeaways
- Hosted means HTTP. A server that lives on another machine is reached at one URL, with the revision, the method and the tool name carried in the request rather than negotiated once at connection time.
- The protocol no longer has sessions. The current revision removed protocol-level sessions and the session header; a server that needs state between calls mints its own handle and passes it as an ordinary argument.
- A restart is a design decision, not an incident. If a request is self-describing, any instance can serve it, so restarts and scaling stop being user-visible events.
- Every public endpoint needs an owner. A lookup that filters by resource id and not by tenant is an authorization hole that no protocol feature will close for you.
- Running a server means owning a log. The calls your server answers are the only record of what an agent did; if they are not queryable per team, an incident is a guess.
- Do this next: write down your transport, the endpoint path, and what is held in memory between requests. Those three lines are the difference between a demo and a deployment.
Running a server is a different job from writing one. A developer gets a tool list returning on a laptop; an operator gets traffic from clients they did not write, on machines that restart, against credentials that leak. Everything below is the operator's half, illustrated with a hosted service's own code: the endpoint record, the tenant check, the state a server keeps, the worker it starts, the backend it fronts and the log it writes.
Cloudflare and the AI gateway: the endpoints a hosted server publishes
A hosted server's public surface is a list of endpoints, and the list itself is something you have to maintain — a name, a URL, the events it accepts, whether it is active, and how many deliveries it has made. Cloudflare's AI gateway documentation makes the same point from the product side: what an operator manages is endpoints and their policy, not protocol messages. Parsing that list is unglamorous and it is where defensive code earns its place, because a list endpoint that throws on one malformed row takes down the page that would have shown you the malformed row:
# lib/dashboard/developer-list-parse.ts — source lines 30–49 (parseEndpointItems)
function parseEndpointItems(data: unknown): EndpointItem[] {
if (!Array.isArray(data)) return [];
return data.map((row) => {
const item = readRecord(row);
const count = readRecord(item._count);
const events = Array.isArray(item.events)
? item.events.map((event) => String(event))
: [];
return {
id: String(item.id),
url: String(item.url),
description: item.description != null ? String(item.description) : null,
events,
isActive: Boolean(item.isActive),
createdAt: new Date(String(item.createdAt)),
_count: { deliveries: Number(count.deliveries ?? 0) },
};
});
}
Three habits are visible here. The guard comes first: anything that is not a list returns an empty list, so a schema change upstream degrades to a blank table rather than a 500. Every field is coerced at the boundary — strings stringified, the active flag forced to a boolean, the timestamp parsed into a real date — so the objects the rest of the dashboard consumes have a fixed shape regardless of what the database returned. And the counts travel with the row rather than in a second request, because an endpoint list that needs one query per endpoint is a list that gets slower as the deployment grows. None of this is protocol-specific; all of it is what hosting adds to a server that worked fine in a terminal.
Memory of a session: what a server keeps between calls
Once a server is hosted, clients arrive without a connection you can reason about, so the state that used to live in a session has to live somewhere you choose. The rule that matters is ownership: a mutation must prove it is allowed to touch the record before it touches it. The excerpt below is that check, with the tenant baked into the lookup rather than checked afterwards:
# lib/webhooks/index.ts — source lines 65–79 (updateEndpoint)
async function updateEndpoint(
id: string,
teamId: string,
data: { url?: string; description?: string; events?: string[]; isActive?: boolean }
) {
const existing = await prisma.webhookEndpoint.findFirst({
where: { id, teamId },
});
if (!existing) throw new Error("Endpoint not found");
return prisma.webhookEndpoint.update({
where: { id },
data,
});
}
The function resolves the record by id and team together, and only then updates by id. That order is the whole point of the section: a lookup by id alone would happily return another team's endpoint, and the update that followed would be an authorization failure that looked like a successful write. The same shape applies to the memory a hosted MCP server keeps between calls. A handle you minted for one caller, or one team, must be resolved against that caller before it is used — otherwise a guessable handle becomes a cross-tenant read. Two failure modes are worth naming because both come from the same mistake: a server that caches a tool result under a key that omits the caller, and one that keeps a single in-process cache for every tenant. Neither is visible in a tool list, and both are visible the moment two tenants use the server at once.
Best LLM gateway: choosing what a hosted server fronts
The best LLM gateway for a workload is rarely the newest one; it is the one whose decision rule you can state out loud. Hosted servers front something — a model, a search backend, a billing provider — and the choice between candidates should be deterministic rather than incidental. The excerpt below makes the rule explicit and returns a decision type instead of a value, which is the pattern worth copying when a server picks a backend:
# lib/billing/select-paddle-subscription.ts — source lines 25–60 (selectBestPaddleSubscription)
function selectBestPaddleSubscription(
candidates: PaddleSubscriptionCandidate[],
opts: { expectedPlan?: Plan; currentPlan: Plan },
): PaddleSubscriptionDecision {
const fullPrice: Array<{ sub: PaddleSubscriptionCandidate; plan: Plan }> = [];
const cancelScheduled: PaddleSubscriptionCandidate[] = [];
for (const sub of candidates) {
const mapped = mappedPlan(sub);
if (!mapped) continue;
if (isCancelScheduled(sub)) {
cancelScheduled.push(sub);
} else {
fullPrice.push({ sub, plan: mapped.plan });
}
}
if (opts.expectedPlan) {
const match = fullPrice.find((c) => c.plan === opts.expectedPlan);
if (match) return { kind: "apply", sub: match.sub };
}
if (fullPrice.length > 0) {
const best = fullPrice.reduce((a, b) =>
planRank(b.plan) > planRank(a.plan) ? b : a,
);
return { kind: "apply", sub: best.sub };
}
const cancelSub = cancelScheduled[0];
if (cancelSub !== undefined) {
return { kind: "cancel_scheduled", sub: cancelSub };
}
return { kind: "none" };
}
Four rules, in order, and each one is checkable. Candidates that cannot be mapped to a plan are dropped rather than guessed at. A candidate the operator expected wins outright, which is how a deliberate choice survives a background reconciliation job. Otherwise the highest-ranked candidate wins, and the comparison is a rank function rather than an expression over prices, so the rule is stable when a price list changes. If nothing is left, a cancellation in flight is returned as such, and if that is absent the answer is an explicit "none" rather than a silent default. The last part is what makes this deployment-grade: a server that returns nothing when it cannot decide is diagnosable, while a server that quietly applies the first candidate is a bug report waiting for a customer to write it.
Progress on agentic RAG: one session page proves it
Progress on an agentic RAG system is hard to see from the outside, because the interesting part is a sequence of calls rather than a single answer. That is why a session view is the first page worth building after the endpoint itself: it turns "the agent is slow today" into a trace with timestamps. The page component below is the entry point of one such view, and it is doing the two things every such page must do — check access, then hand off to something interactive:
# app/[locale]/(protected)/dashboard/logs/session/[traceId]/page.tsx — source lines 19–32 (SessionDetailPage)
async function SessionDetailPage({ params }: PageProps) {
const user = await getCurrentUser();
if (!user?.id) redirect("/login");
const { traceId } = await params;
const id = decodeURIComponent(traceId || "").trim();
if (!id) redirect("/dashboard/logs");
return (
<div className="-mx-4 -mt-4 -mb-4 flex h-full min-h-0 flex-1 flex-col overflow-hidden xl:-mx-8">
<SessionDetailClient traceId={id} />
</div>
);
}
Authorization first: the component resolves the current user and redirects when there is no session, before it touches the trace. Then the path parameter is decoded and trimmed, and an empty identifier redirects to the list rather than rendering an empty shell — a detail that matters because a page reachable by URL is a page people will guess at. The rendering itself is delegated to a client component, keeping the data resolution on the server and the interactivity in the browser. For a hosted server the lesson generalizes: the record you keep per call has to be addressable, the reader has to be authorized for it, and the interactive part belongs to a layer that can poll without re-running the query. A server that logs without an identifier a support engineer can paste is a server that produces anecdotes.
Reasoningbank scaling: the worker a hosted server starts
Reasoningbank scaling patterns all assume the same thing — that the process has somewhere to put work that outlives a single request. In a hosted MCP server that place is a background worker, started once at boot and living as long as the process does:
# backend/smartgate/core/events.py — source lines 45–47 (start)
async def start(self):
self._worker = asyncio.create_task(self._process_events())
logger.info("EventBus started")
Two lines, and both are deployment decisions. The worker is created as a task on the running event loop, which means it shares the process's lifetime: a graceful shutdown has to cancel it, and a hard restart loses whatever it had in flight. And the start is logged, which is the cheapest possible answer to the first question every incident asks — was the worker ever running? Two patterns follow. Anything that must not be lost across a restart has to be persisted before it is acknowledged, not queued in memory; and anything the worker holds in memory while scaling out is held per instance, so a deployment with several instances has several of them. Neither constraint is imposed by the protocol. They are the price of hosting a process that does more than answer requests.
Vercel AI gateway pricing and what hosting actually costs
Hosting cost questions — what an AI gateway charges, what a plan includes, what the ceiling is — are usually answered with a pricing page, and the useful part of a pricing page is the surface a product computes for it. The excerpt below is that surface: base fees, maximums, an intro label and an allowance per plan, returned from one function so the page and the checkout cannot disagree:
# config/billing-savings-share.ts — source lines 104–115 (getMarketingPricingSurface)
function getMarketingPricingSurface() {
return {
proFromUsd: RATES.PRO.baseFee,
teamsFromUsd: RATES.TEAMS.baseFee,
proMaxTotalUsd: RATES.PRO.maxTotalFee,
teamsMaxTotalUsd: RATES.TEAMS.maxTotalFee,
proIntroLabel: "Try Pro — $5 first month",
freeAllowancePro: RATES.PRO.freeSavingsAllowance,
freeAllowanceTeams: RATES.TEAMS.freeSavingsAllowance,
pricingVersion: PRICING_VERSION,
};
}
Read it as a list of the numbers you should be able to state about your own deployment. What is the base fee, and what is the ceiling once usage-based components are included? What is included before anything is charged? Is there an introductory price, and does the product label it as one? And which revision of the price table produced the quote you are looking at? That last field is the one operators forget and later need: without it, two people comparing invoices are comparing two different price tables. The same discipline applies to the cost of hosting your own MCP server — compute, egress, the vendor API behind a tool — except that there the price table is your infrastructure bill, and the only versioning you get is the one you write down.
Agentic search vs RAG: the backend behind one tool
The agentic search versus RAG argument becomes concrete the moment you host the tool. If a tool call goes out to a search backend, the server owns the backend choice, the timeout and the degradation path; if it queries an index you built, the server owns the freshness of that index instead. The excerpt below is the dispatch half of a search container — which backend answers, and what happens when the first one fails:
# backend/smartgate/modules/search/algorithm.py — source lines 102–142 (Search)
class Search:
"""搜索容器 — SearXNG JSON / Firecrawl API / DuckDuckGo Lite。"""
effective_backend: str = ""
def __init__(
self,
search_query: SearchQuery,
settings: SearchRuntimeSettings,
):
self.search_query = search_query
self.settings = settings
self.result_container = ResultContainer()
self.start_time = None
self.actual_timeout = None
self.effective_backend = ""
async def search(self):
import time
self.start_time = time.time()
backend = self.settings.resolved_backend()
self.effective_backend = backend
try:
if backend == "firecrawl":
rows = await self._search_firecrawl()
self.result_container.extend("firecrawl", rows or [])
elif backend == "searxng":
rows: list[dict] = []
try:
rows = await self._search_searxng()
except Exception as e:
logger.warning("SearXNG failed: %s", e)
if self.settings.searxng_fallback:
self.result_container.add_unresponsive_engine(
"searxng", str(e)
)
else:
raise
if rows:
self.result_container.extend("searxng", rows)
The design decisions are all in the failure handling rather than in the happy path. The backend comes from settings and is recorded on the instance, so a request can be traced to the engine that actually served it. A provider that fails is caught, noted on the result container as unresponsive, and either followed by the fallback engine or re-raised depending on one configuration switch — a fallback that is never silently taken. And the whole dispatch is wrapped, so a surprise in one engine becomes a warning plus a partial result rather than a failed tool call. That is what an agentic search tool owes its caller: an answer, or an honest note about which part of the stack did not answer. A retrieval pipeline over your own index has the mirror-image obligation, where the honest failure is "the index is older than the document you are asking about".
AI gateway models and the audit query beneath them
An AI gateway is a model-facing surface with a database behind it, and the database is where the deployment becomes accountable. Whatever an agent did arrives as a row: who called, from where, which tool, at what time. Making that queryable per team is a small piece of code with a large effect on incident time, and it has one non-negotiable property — the tenant scope is a required argument, not a filter someone remembers to add:
# backend/smartgate/core/audit.py — source lines 79–107 (query)
async def query(
self,
team_id: str,
limit: int = 50,
offset: int = 0,
filters: Optional[AuditQueryFilters] = None,
) -> Tuple[List[Dict], int]:
if not team_id or not str(team_id).strip():
raise AuditQueryError("team_id is required")
if self._pool is None:
return [], 0
flt = filters or AuditQueryFilters(scope="full")
where_sql, args = build_audit_where(team_id, flt)
limit_idx = len(args) + 1
offset_idx = len(args) + 2
args.extend([limit, offset])
async with self._pool.acquire() as conn:
total = await conn.fetchval(
f"SELECT COUNT(*)::bigint FROM audit_logs WHERE {where_sql}",
*args[: len(args) - 2],
)
rows = await conn.fetch(
f"SELECT * FROM audit_logs WHERE {where_sql} "
f"ORDER BY timestamp DESC LIMIT ${limit_idx} OFFSET ${offset_idx}",
*args,
)
return [_normalize_audit_row(dict(r)) for r in rows], int(total or 0)
The guard at the top rejects a missing team before any query runs, so an unscoped read is impossible rather than merely discouraged. The list is built once and reused for the count and the page, so the total and the rows it summarises cannot disagree. The ordering is explicit and the page is bounded by a limit and an offset that arrive as bind parameters, which keeps the query parameterised and the memory per request flat. For a hosted server, this is the part that pays for itself the first time someone asks what an agent did last Tuesday at the team level. It is also the part most often left out, because it is not required for a tool call to succeed — which is precisely why it belongs on the list of things hosting adds.
Transport, endpoint and sessions: the deployment shape
The deployment shape follows from three facts about the current revision, and getting them in the right order saves a rewrite. First, the transport: a local server speaks standard input and output and is spawned by its host, while a hosted server speaks HTTP and is reached at a URL. Second, the request: the revision, the method and the tool name travel with each request, and an optional discovery call exists for clients that want the capability list up front rather than after a handshake. Third, the state: protocol-level sessions and the session header are gone, so a server that needs something across calls mints a handle and passes it as an ordinary tool argument.
The operational consequences are concrete. Any instance can serve any request, which means a plain round-robin load balancer is enough and a shared session store is not needed. A restart stops being an event users notice, as long as nothing important was held in memory — the background worker above is the first place to look. Caching becomes a client decision: list results carry a lifetime hint and a cache scope, so a client can hold a tool list without keeping a stream open. And authorization moves to the edge of the deployment, where a gateway can read the method and tool name from headers without parsing the body — keys and authentication is where that decision lands, and the transport change history covers the older two-endpoint shape you will still meet in clients built before the current revision. The documentation and schema map is where to check which revision a given behaviour belongs to before you change a deployment around it. The single-endpoint transport this shape depends on is also the clearest case of a contribution from the first implementer, and anthropic model context protocol traces which chapters of the specification came from Anthropic's own clients and spec work.
How SmartGate compares
There is no single right way to run a server; there are shapes, and each one charges for a different kind of convenience.
| Deployment shape | Transport | What a restart costs | Fits |
|---|---|---|---|
| A server spawned by an editor, per developer | standard input and output | the tool list is rebuilt; nothing else | one person, one machine, one credential |
| A container you host behind your own load balancer | HTTP on one endpoint | nothing if requests are self-describing and state is external | a team that wants its own limits and its own logs |
| A server shared by several tenants | HTTP, with the tenant resolved per call | a cached handle without a tenant is a cross-tenant read, not an outage — it is worse | anyone serving more than one team |
| SmartGate | HTTP at a hosted MCP endpoint, seven tools, per-key limits and a hard budget guard | nothing in the caller's deployment; the audit row is written on the way through | teams that want the operational layer without building it |
The gateway layer is the longer version of that last row, and the local server page covers the first row — standing a server up on your own machine, where the transport question is settled for you.
How to get started
- Choose the transport before writing any tool code. Local process or hosted endpoint is not a detail you can defer; it decides how the server starts, how it is authenticated and how it is restarted.
- Publish one endpoint and one path. A single POST path that accepts JSON-RPC keeps routing, logging and rate limiting in one place instead of behind a per-tool convention.
- Decide what survives a restart, in writing. Anything in memory is per instance and per boot; anything that must outlive a deploy belongs in storage before it is acknowledged.
- Resolve the caller before the resource. Look records up by tenant and identifier together, not by identifier with a check afterwards.
- Keep a per-call record with an addressable identifier. The session-style view is the first thing you will want after the first incident, and it cannot be reconstructed from a log stream.
- Test with a real client. The tool catalogue lists the surface to exercise, and the handshake debugging tools show which request failed when a client behaves as if the server had no tools.
Start on the free tier — 2 million tokens a month and all seven tools — with start free; per-plan limits are on the pricing page, the endpoint and tool shapes are in the product docs, and contract traffic starts with the contact form. If the naming in a client's configuration file is what sent you here, the acronym mapped out is the page to read next, and the cluster's protocol primer covers the actors themselves.
Frequently Asked Questions
Limitations and what this does not do
- One excerpt is a window. The search container is a 232-line file, so only its class header, its constructor and the backend dispatch are quoted; the surrounding branches, the individual engines and the normalisation helpers are described rather than shown.
- This is a hosted product's code, not a specification of hosting. The excerpts come from one service's endpoint list, tenant check, provider selection, session view, worker, pricing surface, search dispatch and audit query. They show the shapes that hold up in production; they are not requirements the protocol imposes.
- The stateless shape is newer than some clients. A client built against an older revision may still expect a session header; the changelog entries for the current revision are where to check what changed and when.
- Nothing here secures a deployment by itself. Tenant scoping, credential rotation, per-key limits and log retention are decisions this page describes and cannot make for you. Where the protocol's own authorization guidance applies, it applies to servers acting for an end user, which is a larger problem than a single-tenant endpoint.
- Cost figures are somebody else's price table. The pricing surface quoted here belongs to this product; your hosting bill is a different table, and the only versioning it gets is the one you write down.
- This page is not a server tutorial. It covers what changes when a server is hosted. The client half is the Claude client page, and how MCP and A2A differ covers the delegation question a hosted server eventually meets when the caller is another agent rather than a person.
Sources
- Model Context Protocol — specification for the current revision (2026-07-28), including the transport and versioning chapters: https://modelcontextprotocol.io/specification/2026-07-28 · https://modelcontextprotocol.io/specification/2026-07-28/changelog
- Model Context Protocol — transports and version negotiation in the previous revision (2025-11-25): https://modelcontextprotocol.io/specification/2025-11-25/basic/transports · https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle
- Model Context Protocol — authorization and security best practices: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization · https://modelcontextprotocol.io/specification/2026-07-28/basic/security_best_practices
- Model Context Protocol — the official registry, for discovering servers: https://modelcontextprotocol.io/registry
- Cloudflare — AI Gateway, and the endpoint/policy surface an operator maintains: https://developers.cloudflare.com/ai-gateway/
- Vercel — AI Gateway pricing (the vendor page behind the pricing demand phrase): https://vercel.com/docs/ai-gateway/pricing
- SmartGate — product documentation, pricing and sales contact: https://smartgate.network/docs · https://smartgate.network/pricing · https://smartgate.network/contact
Method note
The code in this article is not transcribed. Seven of the eight blocks are whole slice bodies and one is a window: each was cut out of the slice body the SmartGate slice API returned and re-asserted byte-for-byte as a substring of that body before publication, with the file and the exact source lines recorded on the first line inside every fence. Symbols were pinned by whole-name containment (rule A level 2) and confirmed by the service's slot-proof endpoint — 8 of 8 planned sections pinned, 0 abstentions, 0 misses. The seventh block quotes lines 102–142 of a 232-line search container; the engine implementations below that window are described in prose rather than quoted, because the point of the section is the dispatch decision and not the fifteen hundred lines of HTTP clients behind it.
Demand figures come from this project's own keyword run, recorded in research_brief.md and
search_volume.json: the section phrases ai gateway cloudflare (140), agent grant memory of a killer (110), best llm gateway (110), progress agentic rag (110),
reasoningbank scaling agent self-evolving with reasoning memory (110), vercel ai gateway pricing
(110), agentic search vs rag (90) and ai gateway models (90), each with its competition band. The
page's main term model context protocol server measured 1,300 a month in the same pass with a
difficulty of 77, the hardest term in this cluster batch — which is why this page was written for the
operator who already has a server and has to keep it up.
Slice provenance
| # | SERP keyword | Symbol | File | Source lines | How it was pinned | sha256(12) |
|---|---|---|---|---|---|---|
| 1 | ai gateway cloudflare | parseEndpointItems |
lib/dashboard/developer-list-parse.ts |
30–49 | rule A L2 → slot-proof | 5d8fbd55a4a7 |
| 2 | agent grant memory of a killer | updateEndpoint |
lib/webhooks/index.ts |
65–79 | rule A L2 → slot-proof | c08ed8ab4d08 |
| 3 | best llm gateway | selectBestPaddleSubscription |
lib/billing/select-paddle-subscription.ts |
25–60 | rule A L2 → slot-proof | d27362d09a44 |
| 4 | progress agentic rag | SessionDetailPage |
app/[locale]/(protected)/dashboard/logs/session/[traceId]/page.tsx |
19–32 | rule A L2 → slot-proof | 15ba7eafcd59 |
| 5 | reasoningbank scaling agent self-evolving with reasoning memory | start |
backend/smartgate/core/events.py |
45–47 | rule A L2 → slot-proof | ae3a2504b40c |
| 6 | vercel ai gateway pricing | getMarketingPricingSurface |
config/billing-savings-share.ts |
104–115 | rule A L2 → slot-proof | c542ec3abfb4 |
| 7 | agentic search vs rag | Search |
backend/smartgate/modules/search/algorithm.py |
102–142 | rule A L2 → slot-proof | 5d88cd6aed7c |
| 8 | ai gateway models | query |
backend/smartgate/core/audit.py |
79–107 | rule A L2 → slot-proof | acf58f151704 |
Every fenced block above was cut from the slice body and re-asserted against it byte-for-byte before publication. 8 of 8 sections pinned, 0 abstentions, 0 misses.