MCP Server Explained: Endpoint, Tool Registry, Sessions
An MCP server is the process that exposes tools to an AI host. It answers initialize, tools/list, and tools/call as JSON-RPC, most often at one Streamable HTTP endpoint, and it does not have to keep a session. SmartGate runs exactly such a server with seven tools, one endpoint, and no session id to manage.
Short answer: An MCP server is the process that exposes tools to an AI host. It answers
initialize, tools/list, and tools/call as JSON-RPC, most often at one Streamable HTTP
endpoint, and it does not have to keep a session. SmartGate runs exactly such a server with seven
tools, one endpoint, and no session id to manage.
Key takeaways
- One endpoint, one POST route. The whole HTTP surface is a mounted Streamable HTTP app, not a family of REST paths.
- A server is a registry plus a policy. The registry advertises seven tools; the gateway clamps what a team may call and records every call that arrives.
- Sessions are optional, and statelessness is a choice with a cost. Accepting read-only tool traffic during the handshake race serves clients that connect, list, and disconnect quickly.
- Every host reads a different config file. Claude Desktop, Cursor, Windsurf, Hermes, and OpenClaw each expect their own filename and their own header block.
- Shared state lives outside the process. Rate-limit buckets and budget counters need a store the HTTP server can reach, which is why the gateway resolves a storage mode at boot.
- Do this next: point a client at the endpoint, call
tools/list, and read the annotations before you write any integration code.
The mcp protocol surface: one POST endpoint per server
An MCP server is defined by what it answers, not by how many endpoints it publishes. The protocol's HTTP transport is a single endpoint that takes POST bodies; capability negotiation, tool discovery, and tool invocation all travel through it. SmartGate mounts the Streamable HTTP app on one path and lets the framework handle the protocol:
# backend/smartgate/api/mcp.py — source lines 399–406 (mount_mcp_routes)
def mount_mcp_routes(app: FastAPI) -> None:
"""Expose POST /mcp (Streamable HTTP, stateless)."""
apply_mcp_session_compat()
streamable_app = mcp.streamable_http_app()
streamable_app.router.lifespan_context = _noop_starlette_lifespan(streamable_app)
app.mount("/mcp", streamable_app)
logger.info("MCP Streamable HTTP at POST /mcp")
Three decisions are visible in eight lines. The route is mounted rather than re-implemented, so
the framework's protocol handling stays authoritative. apply_mcp_session_compat() runs
before the mount, because the compatibility patches have to be in place when the first request
arrives rather than when the first session is created. And the mounted app's lifespan is replaced
with a no-op, because nesting a second ASGI application inside a larger FastAPI process would
otherwise run a second startup lifecycle — the failure that presents itself as "the tools work
locally and not in production". A local stdio server has no analogue of any of this: its
transport is a pair of pipes and its session is the process lifetime. The endpoint shape itself
is documented on the MCP endpoint page.
What an mcp inspector can see: transport inferred per request
An inspector — or any audit view — starts from a question the raw request does not answer: did this call arrive over MCP or over plain REST? The gateway records the answer per request, derived from the path:
# backend/smartgate/core/audit_enrichment.py — source lines 29–32 (infer_transport)
def infer_transport(path: str) -> str:
if path.startswith("/mcp"):
return "mcp_sse"
return "rest"
This is a deliberately cheap heuristic. Anything under the MCP prefix is labelled as the MCP transport and everything else is REST, which is the right classification to put in the audit layer rather than the protocol layer: a wrong label produces a misleading log line, while a wrong validation produces an outage. If you are choosing an inspector to point at your own server, the property that matters is whether it speaks the same transport your server publishes, not how many panels it draws — we compare the options in MCP inspector alternatives.
The mcp tools registry: seven tools through one function
A registry is what turns a server into something an agent can use: the names and typed inputs
advertised by tools/list, and the code path that runs when one of them is called. SmartGate
declares every tool inside a single function, and each declaration follows the same shape:
# backend/smartgate/api/mcp.py — source lines 106–148 (register_mcp_tools)
def register_mcp_tools(server: FastMCP) -> None:
"""Register all 7 smart_* tools on a FastMCP instance."""
@server.tool(
name="smart_fetch",
description=TOOL_DESCRIPTIONS["smart_fetch"],
annotations=tool_annotations("smart_fetch"),
)
async def smart_fetch(
url: str = Field(description="Full HTTP or HTTPS URL to fetch."),
timeout: int = Field(default=30, description="HTTP timeout in seconds."),
) -> str:
_, registry = _app_state()
module = registry.get("fetch")
ctx = _tool_ctx()
return await _run_with_audit(
"fetch",
ctx,
module.process(ctx, url=url, timeout=timeout),
{"url": url},
)
@server.tool(
name="smart_search",
description=TOOL_DESCRIPTIONS["smart_search"],
annotations=tool_annotations("smart_search"),
)
async def smart_search(
query: str = Field(description="Search query string."),
max_results: int = Field(
default=10,
description="Maximum number of results to return (1–50).",
),
) -> str:
_, registry = _app_state()
module = registry.get("search")
ctx = _tool_ctx()
return await _run_with_audit(
"search",
ctx,
module.process(ctx, query=query, max_results=max_results),
{"query": query},
)
Every tool is declared once — name, description pulled from a shared table, annotations derived
from the tool's risk — and every body ends in the same audited exit. The two tools in this window
show the pattern end to end: smart_fetch takes a URL and a timeout, smart_search takes a
query and a result cap, and both hand a coroutine plus a small dict of audit fields to
_run_with_audit. That shared ending is why a tools/call response looks the same no matter
which of the seven tools ran, and why an audit row exists for every call that reached the
gateway. Inputs, defaults, and the full list are in the
MCP tools reference.
anthropic mcp hosts: one config filename per client
Anthropic's client is not the only client, and each host reads its own configuration file. Getting that wrong is the most common reason a correctly built server looks broken:
# lib/connect/mcp-config-templates.ts — source lines 249–264 (mcpConfigFilename)
function mcpConfigFilename(platform: McpPlatform): string {
switch (platform) {
case "Claude Desktop":
return "claude_desktop_config.json";
case "Cursor":
return "mcp.json";
case "Windsurf":
return "mcp_config.json";
case "Hermes":
return "mcp.json";
case "OpenClaw":
return "openclaw-smartgate-snippet.json";
default:
return "smartgate-mcp.json";
}
}
The mapping is not decoration; it is the difference between "I added a server" and "nothing
happened". Claude Desktop reads claude_desktop_config.json, Cursor and Hermes both read
mcp.json, Windsurf reads mcp_config.json, and OpenClaw takes a snippet file. Deriving the
filename from the platform means the connect page and the server's own code can
never disagree about which file a given host expects. A hosted server shortens the loop further:
you paste one JSON block, and the client's next request is the server's first request — no
package to install and no Python environment to keep alive.
A python mcp server config, generated instead of copied
The config a host needs is small enough to write by hand and easy enough to get subtly wrong, so SmartGate renders it from the same values it serves:
# lib/connect/mcp-config-templates.ts — source lines 161–181 (buildGenericMcpConfigJson)
function buildGenericMcpConfigJson(
mcpUrl: string,
apiKeyPlaceholder: string = API_KEY_PLACEHOLDER,
): string {
return JSON.stringify(
{
mcpServers: {
smartgate: {
type: "streamable-http",
url: mcpUrl,
headers: buildMcpAuthHeaders(
apiKeyPlaceholder,
PLATFORM_AGENT_ID.Generic,
),
},
},
},
null,
2,
);
}
Four fields do the work: type: "streamable-http" selects the HTTP transport instead of stdio,
url points at the server, and headers carries the API-key placeholder that the client
replaces with a real key. Because the block is generated, the placeholder and the field names
cannot drift away from what the server accepts. The python here is about the SDK you would use
locally — a Python server with FastMCP, or the equivalent TypeScript SDK — not about the wire
format: a client that speaks the transport can talk to either. If you are writing that server
yourself, the message format is the part to
get exactly right first. Writing that server rather than pointing a client at one is a separate job,
and the Python MCP server tutorial takes the SDK's fifteen
lines through keys, limits and one audit row per call.
What an mcp client must get right
Clients differ in the details the specification leaves open, and a server that meets them halfway
serves more of them. Three behaviours are worth designing for. A client may send an empty
params array for methods that take no arguments. It may call tools/list immediately after
connecting, before it has finished the initialization handshake. And it may disconnect without
ever sending notifications/initialized. None of those are protocol violations, and each of them
breaks a strict implementation. The client's own obligations are smaller — initialize, list,
call, and respect the annotations it was shown — but they are not optional: a client that ignores
readOnlyHint asks the user for a confirmation it did not need, or skips one it did. The
client-side checklist is in
build an MCP-compatible client.
mcp vs api: why a tool call is not an endpoint
The practical difference between an MCP server and a REST API is where the intelligence sits. A REST API publishes endpoints and leaves orchestration to the caller; an MCP server publishes named capabilities and lets the client discover them at runtime:
# backend/smartgate/api/mcp.py — source lines 302–309 (smart_pipe)
@server.tool(
name="smart_pipe",
description=TOOL_DESCRIPTIONS["smart_pipe"],
annotations=ToolAnnotations(
title="Pipeline orchestrator",
readOnlyHint=False,
),
)
# backend/smartgate/api/mcp.py — source lines 382–387 (smart_pipe)
return await _run_with_audit(
"pipe",
ctx,
_identity_result(tool_result),
audit_extra,
)
The same registration function that advertises smart_fetch also declares smart_pipe, and
readOnlyHint=False in that declaration is a machine-readable statement that invoking it can
change state — something a REST route cannot say about itself. Inside the handler the gateway
builds a PipelineEngine, runs a template or an explicit step list, and folds a failed step's
error into the result before the audited exit. A REST integration would leave that folding to the
caller. The trade runs in both directions: MCP costs you a protocol implementation on the client
side and saves you from writing dispatch, metadata, and per-call accounting on the server side.
What an mcp gateway clamps before a tool call runs
An MCP gateway is where plan limits stop being documentation and start being code. The policy document a team edits is clamped against the capabilities of the plan that pays for it, before the policy takes effect:
# lib/settings/clamp-gateway-policy.ts — source lines 4–35 (clampGatewayPolicyToPlan)
function clampGatewayPolicyToPlan(
policy: GatewayPolicyV1,
caps: PlanCapabilities,
): GatewayPolicyV1 {
const rpm = Math.min(policy.playground.rpm, caps.maxPlaygroundRpm);
let dailyCap = policy.playground.daily_request_cap;
if (caps.minPlaygroundDailyCap != null) {
dailyCap = caps.minPlaygroundDailyCap;
} else if (
dailyCap != null &&
caps.maxPlaygroundDailyCap != null
) {
dailyCap = Math.min(dailyCap, caps.maxPlaygroundDailyCap);
}
return {
...policy,
playground: {
rpm,
daily_request_cap: dailyCap,
},
governance: {
allow_member_tool_overrides: caps.memberToolOverridesAllowed
? policy.governance.allow_member_tool_overrides
: false,
allow_integrator_hmac_budget: caps.l2HmacBudgetAllowed
? policy.governance.allow_integrator_hmac_budget
: false,
},
};
}
Three clamps are visible in the excerpt. The requests-per-minute value becomes the lower of what the policy asks for and what the plan allows, so a copied policy cannot raise a ceiling. The daily request cap applies the plan's floor where the plan defines one, and the plan's ceiling otherwise — a floor is what makes a minimum daily allowance mean something. Two governance switches are forced off unless the plan grants them, which is how member overrides and integrator budget keys stay inside the plan. Clamping instead of rejecting is the deliberate choice: an over-specified policy keeps working, at the plan's numbers, and the difference stays visible in the stored policy.
The mcp specification: what is mandatory and what is not
The specification is thin by design, and knowing which half is normative saves a lot of guessing. The message layer is JSON-RPC 2.0 with MCP-defined methods (MCP specification). A server must implement at least one transport: stdio, or Streamable HTTP, which defines a single endpoint and makes session management optional (transports). Server features are tools, resources, and prompts — a server that implements only tools is still a conforming server (tools). Authorization is optional as well: where an HTTP transport supports it, the server acts as an OAuth 2.1 resource server (authorization). Everything a production gateway adds — rate limits, quotas, audit retention — sits outside the normative core, which is exactly why it is worth putting in one place. Section-by-section, the specification walkthrough covers the rest. What a gateway can meter, and where it can only state a boundary, across tools, resources and prompts is the subject of MCP resources, prompts and sampling.
mcp spec revisions are dated, and clients pin one
Revisions of the specification are dated documents, not a rolling page, and the date is part of
the contract. When a client sends initialize, it proposes a protocol version and the server
answers with the version it will speak: the requested one when it is supported, otherwise the
server's own latest. That rule is what lets a client written against an older revision talk to a
newer server, and it is also why "which version do you implement?" has a precise answer. The
practical consequences are small but real. Pin a revision in your own test suite and assert the
transport you actually serve, because a transport change is the one revision difference a client
cannot negotiate around. Treat newer revisions as additions until a client you care about
requires them. When someone reports a handshake failure, the version pair is the first thing
worth printing, and the second is the transport. The version and transport history is tracked in
MCP protocol versions and transports.
The mcp server list never changes; the runtime state does
The tool list is a constant: smart_fetch, smart_search, smart_context_gate, smart_dedup,
smart_budget_guard, smart_memory, and smart_pipe. What changes between deployments is where
the server keeps the counters those tools depend on. A stateless HTTP server holds nothing in
memory it can rely on, so rate-limit buckets, budget counters, and team memory have to live in a
store the process can reach:
# lib/redis/config.ts — source lines 26–29 (isTcpRedisConfigured)
function isTcpRedisConfigured(): boolean {
const url = redisUrl();
return Boolean(url && (url.startsWith("redis://") || url.startsWith("rediss://")));
}
Checking for a TCP Redis URL is the cheap half of that decision. The helper accepts only the
redis:// and rediss:// schemes and returns a boolean instead of raising, so a missing store
degrades one feature rather than failing a request — and an operator reading the check can see
immediately why a counter stopped moving.
A mcp server example: resolving the storage mode at boot
The concrete example is one function that decides how the server will keep state:
# lib/redis/config.ts — source lines 38–42 (resolveRedisMode)
function resolveRedisMode(): RedisMode {
if (isUpstashRestConfigured()) return "upstash";
if (isTcpRedisConfigured()) return "tcp";
return "none";
}
The preference order carries the reasoning. A REST-based store is tried first because serverless deployments may open no outbound TCP sockets at all; a direct Redis URL comes second; and "none" is the honest fallback rather than a silent default. The modes are not equivalent — one supports shared counters across instances and the others do not — so the mode a server resolves at boot is the mode its rate limits and quotas will behave like. Check it at deployment time instead of discovering it from a support ticket.
How SmartGate's mcp server compares with a local one
| Transport | Session | Tools | What you pay | |
|---|---|---|---|---|
| SmartGate (hosted) | Streamable HTTP at one POST endpoint | Stateless; no session id required | Seven metered tools | Free tier: 2M tokens a month, 120 MCP requests a minute per key (pricing) |
| Local stdio server | stdin and stdout | The process lifetime | Whatever you implement | Your own machine |
| Self-hosted HTTP server | Your own HTTP layer | Yours to design | Yours | Your own infrastructure |
| Hand-rolled JSON-RPC shim | Whatever you build | Yours to invent | Bugs proportional to what you re-implement | Your time |
The honest reading is that the protocol is the easy part. JSON-RPC is small, tools/list and
tools/call are small, and the specification is thin. What a gateway adds is the part you would
otherwise build badly under time pressure: one endpoint every host can reach, a registry that
cannot drift from its documentation, plan-clamped limits, and an audit row per call. If your
server serves one process and one client, the specification is enough. If it serves hosts you do
not control, the compatibility layer and the policy layer are not optional extras.
How to get started
- Point a client at the endpoint.
POST https://smartgate.network/api/mcpwith a bearer key; the connect page generates the exact block for your host. - Call
tools/listonce and read it. Seven tools come back, each with a title and a read-only hint — that response is the contract your client can rely on. - Call
tools/callonsmart_fetchwith a URL, and confirm the result arrives as JSON-RPC content rather than an error object. - Watch the audit row appear. Activity Logs record the call; if it is missing, the request never reached the gateway.
- Then read the two sibling pages — the JSON-RPC shapes in the message format, and the protocol end to end in Model Context Protocol explained. A full worked walk from a configuration entry to a first recorded call is in MCP server example.
Start on the free tier: 2M tokens a month, all seven tools, and 120 MCP requests a minute per key, then compare per-key limits and log retention on the pricing page.
Frequently Asked Questions
What is an MCP server, in one sentence?
A process that exposes tools to an AI host over JSON-RPC, normally at one Streamable HTTP endpoint, answering initialize, tools/list, and tools/call.
Do I need a session id?
No. Sessions are optional in the HTTP transport, and this server runs stateless: a client can connect, list, and call without a session, and a configuration change does not force a reconnect.
Which tools does the server expose?
Seven: smart_fetch, smart_search, smart_context_gate, smart_dedup, smart_budget_guard, smart_memory, and smart_pipe.
Do I have to write the server in Python?
No. The transport is the contract; the SDK is a convenience. A TypeScript or Go server that speaks Streamable HTTP is just as usable from the same client.
Can an MCP server be used without a gateway?
Yes, and for one process and one client that is usually right. The gateway earns its place when several hosts, several teams, or a budget are involved.
What happens if the backing store is missing?
The server still answers tool calls; counters that need shared state degrade instead of blocking traffic, and the failure shows up as a counter that stops moving rather than as a failed request.
Limitations and what this does not do
- Statelessness is a trade, not a free win. Accepting read-only tool traffic during the initialization race gives up the guarantee that a session was properly established first.
- The compatibility layer is a compatibility layer. It smooths over what real clients send; it does not repair malformed JSON, and a client with a broken transport still fails.
- Annotations are hints, not enforcement. A read-only hint shapes what a host asks the user; the controls that actually bound a key are the rate limit, the budget cap, and the key's scope.
- Tool lists and protocol versions move. The seven tools are stable, but supported versions
and tool metadata live in code and change between releases; read
tools/listrather than trusting a cached copy of this page. - This is not a model host. It does not run your model and does not choose one for you; it governs the calls that leave your agent.
Sources
- Model Context Protocol — specification: https://modelcontextprotocol.io/specification/2026-07-28
- Model Context Protocol — transports, including stateless Streamable HTTP: https://modelcontextprotocol.io/specification/2026-07-28/basic/transports
- Model Context Protocol — server tools (
tools/list,tools/call): https://modelcontextprotocol.io/specification/2025-06-18/server/tools - Model Context Protocol — authorization: https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization
- Anthropic — introducing the Model Context Protocol: https://www.anthropic.com/news/model-context-protocol
- SmartGate — product site and pricing: https://smartgate.network