SmartGateSmartGate

MCP Specification Walkthrough: How to Read the Spec

The MCP specification is a short document that reads as a map rather than a manual: a JSON-RPC message layer, one mandatory transport choice, an optional authorization chapter, and three feature chapters of which you may implement one. This walkthrough follows that order — lifecycle, capabilities, error shapes and versioning — and shows where a real server has to add rules the document…

Short answer: The MCP specification is a short document that reads as a map rather than a manual: a JSON-RPC message layer, one mandatory transport choice, an optional authorization chapter, and three feature chapters of which you may implement one. This walkthrough follows that order — lifecycle, capabilities, error shapes and versioning — and shows where a real server has to add rules the document deliberately leaves out.

Key takeaways

  • Read the chapters in dependency order. Message layer, then transport, then lifecycle, then features; authorization and utilities last, because they are optional.
  • Normative is a small set. JSON-RPC 2.0 framing, one transport, and the request/response shapes for the features you advertise. Everything a gateway adds is outside it.
  • Capabilities are negotiated per session, not published. A client learns what a server supports from initialize, and a server that implements only tools is still conforming.
  • Revisions are dated documents. The version the client proposes and the version the server answers with are both visible in one exchange, which is what makes a handshake failure diagnosable.
  • Error shapes are where the document is thinnest. Rate limits, quotas and retention are yours to define — and yours to document.
  • Do this next: print the version pair and the transport for every failing session, then read the chapters your deployment actually exercises.

The model context protocol document: the chapters that matter

Read the specification top to bottom once and the shape is obvious: a message layer, a transport layer, then feature chapters. Read it as an implementer and the order changes, because the transport decides what the other chapters mean. The endpoint the document implies is one HTTP route that takes POST bodies:

# 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")

The docstring is the whole transport chapter in one line — Streamable HTTP, stateless, one path. The three statements under it are the parts of the document that have runtime consequences. The mount happens after the compatibility patches are applied, because the session behaviour they change is framework behaviour, not protocol behaviour. The mounted application's lifespan is replaced with a no-op, because a nested application that starts its own lifecycle inside a larger process fails in a way the specification does not describe. And the log line is a deliberate statement of what is running: one POST endpoint, no second route to keep alive. Everything else in the document — capabilities, tools, prompts, resources, utilities — is layered on top of that decision.

mcp capabilities are negotiated once, per session

The capabilities object is the document's way of saying that nothing is assumed. A client sends what it supports, a server answers with what it supports, and both sides act on the intersection for the life of that session:

# 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"

The function is about observability rather than negotiation, and that is the point worth taking from it. The wire says nothing about which transport a request arrived on once the session is established; the label has to be derived afterwards, from the path, by whoever records the call. Capability negotiation and transport identification are two different problems with two different failure modes: getting the first wrong breaks the session, getting the second wrong produces a misleading audit line. The specification covers the first. The second is a decision your server makes about how honest its telemetry is — and a wrong label is much cheaper to live with than a wrong validation.

Version negotiation and the session it applies to

Versioning in this protocol is a single exchange rather than a configuration file. The client proposes a revision in initialize; the server answers with the one it will speak. What happens next depends on how strict the two sides are about ordering:

# backend/smartgate/api/mcp_session_compat.py — source lines 89–103 (apply_mcp_session_compat)
def apply_mcp_session_compat() -> None:
    """Idempotent patches applied before mounting MCP SSE."""
    global _PATCHED
    if _PATCHED:
        return

    ServerSession._received_request = _compat_received_request  # type: ignore[method-assign]
    ServerSession._received_notification = _compat_received_notification  # type: ignore[method-assign]

    if not hasattr(_stateless_server_run, "_orig"):
        _stateless_server_run._orig = lowlevel_server.Server.run  # type: ignore[attr-defined]
        lowlevel_server.Server.run = _stateless_server_run  # type: ignore[method-assign]

    _PATCHED = True
    logger.info("MCP session compat enabled (stateless SSE + relaxed init gate)")

The patches are idempotent — a module-level flag returns early, and each attribute is only replaced if it has not been replaced before — which is what lets the function run on a module import path and still be safe to call twice. The two replaced methods are the ones that guard request and notification ordering; the run override is what lets a stateless session skip the handshake state machine that a stateful session depends on. This is the practical reading of the version chapter: the document tells you which revision is supported, and it does not tell you how lenient to be with clients that connect, list and call before they finish initializing. That choice is yours, it is observable in the patches, and it is the one most likely to be mistaken for a protocol bug.

The handshake a host performs before the first tool call

Between a client and its first tool call there is a small, host-specific ritual: a file to edit, a URL to paste, a header to set, and a restart. The document describes the protocol side of that exchange and says nothing about the plumbing, which is why the plumbing is generated per platform:

# lib/connect/mcp-config-templates.ts — source lines 313–320 (getMcpPlatformHints)
function getMcpPlatformHints(platform: McpPlatform): McpPlatformHint[] {
  const docsLink: McpPlatformHint = {
    kind: "link",
    before: "Official docs: ",
    href: PLATFORM_DOCS_URL[platform],
    label: platform === "Generic" ? "MCP Streamable HTTP spec" : `${platform} MCP setup`,
    after: ".",
  };
# lib/connect/mcp-config-templates.ts — source lines 442–461 (getMcpPlatformHints)
    case "Generic":
    default:
      return [
        docsLink,
        {
          kind: "text",
          children:
            "Common pattern: mcpServers + url pointing at the Streamable HTTP endpoint + Authorization Bearer header.",
        },
        {
          kind: "text",
          children:
            "If the host lacks native Streamable HTTP support, bridge with: npx mcp-remote <url> --header Authorization:Bearer <key> (stdio command in mcpServers).",
        },
        {
          kind: "text",
          children: `Include ${AGENT_PLATFORM_HEADER}: ${PLATFORM_AGENT_ID.Generic} in headers (included in the snippet below) so Audit Logs shows the correct agent platform.`,
        },
        ...authAndUrlHints(),
      ];

Two halves of the same answer. The first is a documentation link with a label that changes for the generic case — the official transport specification instead of a vendor's setup page. The second is the branch that applies when the host is not on the vendor list: the common shape (a server map, a URL, a bearer header), a bridge for hosts that lack native Streamable HTTP support, and the platform header that keeps the audit view legible. Read as a specification exercise, the handshake is one initialize request and one notifications/initialized. Read as an integration, it is the four things above — and most "the server is broken" reports are one of them.

Server capabilities: reading the tools list as a contract

The tools chapter is short, and it defines only three methods: list what you have, call one of them, and be told when the list changes. What a tool is remains the server's decision, and the shape is worth reading once:

# backend/smartgate/api/mcp.py — source lines 176–196 (register_mcp_tools)
    @server.tool(
        name="smart_dedup",
        description=TOOL_DESCRIPTIONS["smart_dedup"],
        annotations=tool_annotations("smart_dedup"),
    )
    async def smart_dedup(
        texts: list[str] = Field(description="List of text passages to deduplicate."),
        threshold: float = Field(
            default=0.9,
            description="Similarity threshold (0.0–1.0); higher keeps fewer duplicates.",
        ),
    ) -> str:
        _, registry = _app_state()
        module = registry.get("dedup")
        ctx = _tool_ctx()
        return await _run_with_audit(
            "dedup",
            ctx,
            module.process(ctx, texts=texts, threshold=threshold),
            {"threshold": threshold},
        )

The declaration carries the three things a client needs to reason about before it calls anything: the name, a description written for a model rather than for a human, and a typed input schema — here one list and one similarity threshold with a documented range. The body adds a fourth: the dispatch resolves the module from a registry and returns through a shared audited exit, so the tools/call response shape is identical for every tool the server exposes. That uniformity is not in the document; it is what a server gains by treating the tools chapter as a contract with its own callers instead of a checklist to satisfy.

A simulator's first call: the shape of a tools/call result

If you are writing a simulator or an inspector, the result envelope is the part to match exactly, because a client that can parse one tool call often believes it can parse all of them:

# backend/smartgate/api/mcp.py — source lines 109–126 (smart_fetch)
@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},
        )

The declaration and the dispatch are the same shape as the tool above, which is the point: name and description come from a shared table, annotations are derived from the tool's risk, the module comes from the registry, and the audited exit wraps the coroutine. What varies between tools is only the argument list and the audit payload — a URL and a timeout here, a query and a result cap elsewhere. For a simulator, that means the envelope is worth matching and the arguments are worth generating: initialize once, tools/list to learn the vocabulary, then tools/call with each tool's own fields. Match the envelope and you can test any server; hand-write one request per tool and you are testing your own fixtures.

Token usage under the authorization chapter

Authorization is optional in the specification — where an HTTP transport supports it, the server acts as a resource server for a bearer token and the document stops there. What that token resolves to is implementation:

# lib/api-keys/index.ts — source lines 92–118 (validateApiKey)
async function validateApiKey(
  rawKey: string
): Promise<{ valid: boolean; teamId?: string; keyId?: string }> {
  const hash = hashKey(rawKey);
  const key = await prisma.apiKey.findUnique({
    where: { keyHash: hash },
    select: {
      id: true,
      teamId: true,
      revokedAt: true,
      expiresAt: true,
    },
  });

  if (!key || key.revokedAt) return { valid: false };
  if (key.expiresAt && key.expiresAt < new Date()) return { valid: false };

  await prisma.apiKey.update({
    where: { id: key.id },
    data: {
      lastUsedAt: new Date(),
      usageCount: { increment: 1 },
    },
  });

  return { valid: true, teamId: key.teamId, keyId: key.id };
}

Three decisions live in one function. The key is stored hashed, so the lookup is by hash rather than by value, and a stolen database read does not yield a usable credential. Revocation and expiry are checked in the same pass, and both return the same uninformative result — a caller learns that the token is invalid, not why. And the counter update happens on the allowed path only, which is what makes usageCount and lastUsedAt meaningful: they count calls that were authorised, not attempts. None of that is in the specification, and every one of those choices changes what a "token usage" report can tell you afterwards.

A bearer token or a signed body: two shapes of proof

The document's authorization chapter describes one mechanism. A server that also accepts signed requests has to define the other half itself, and the two live side by side:

# lib/crypto.ts — source lines 57–69 (hmacSha256Hex)
async function hmacSha256Hex(key: string, data: string): Promise<string> {
  const crypto = ensureCrypto();
  const keyBytes = textEncoder.encode(key);
  const cryptoKey = await crypto.subtle.importKey(
    "raw",
    keyBytes,
    { name: "HMAC", hash: "SHA-256" },
    false,
    ["sign"],
  );
  const sig = await crypto.subtle.sign("HMAC", cryptoKey, textEncoder.encode(data));
  return bytesToHex(new Uint8Array(sig));
}

The function is the standard construction in a few lines: import the raw secret as an HMAC key, sign the encoded body with SHA-256, hex-encode the result, and compare. What matters for a specification walkthrough is where the choice belongs. A bearer token is ambient — whoever holds it is the caller, which is why revocation, expiry and per-key accounting all matter. A signature is per-request — it proves the body was not altered in flight and it cannot be replayed as a different request, but it says nothing about the caller's remaining allowance. Servers that support both should document which one a given tool call used, because the two have different failure modes when they go wrong.

Rate limiting: the error shapes the specification leaves to you

The specification defines an error object and a small set of protocol errors. It does not define what a server should do when a key is over its limit, which means the shape of that refusal is a design decision:

# lib/rate-limit.ts — source lines 20–60 (rateLimit)
async function rateLimit(
  key: string,
  config: RateLimitConfig = { limit: 60, window: 60 },
): Promise<RateLimitResult> {
  const redis = getRedis();
  const now = Date.now();
  const windowStart = now - config.window * 1000;
  const redisKey = `ratelimit:${key}`;

  const cleaned = await redis.zremrangebyscore(redisKey, 0, windowStart);
  if (cleaned === undefined) {
    return { success: true, limit: config.limit, remaining: config.limit, reset: Math.ceil(now / 1000) + config.window };
  }

  const count = await redis.zcard(redisKey);
  const current = Number(count ?? 0);

  if (current >= config.limit) {
    const members = await redis.zrange(redisKey, 0, 0, { withScores: true });
    const resetTimestamp = Array.isArray(members) && members.length >= 2
      ? Math.ceil(Number(members[1] ?? now) / 1000)
      : Math.ceil(now / 1000) + config.window;

    return {
      success: false,
      limit: config.limit,
      remaining: 0,
      reset: resetTimestamp,
    };
  }

  await redis.zadd(redisKey, { score: now, member: `${now}-${Math.random().toString(36).slice(2)}` });
  await redis.expire(redisKey, Math.max(config.window, 60));

  return {
    success: true,
    limit: config.limit,
    remaining: config.limit - current - 1,
    reset: Math.ceil((now + config.window * 1000) / 1000),
  };
}

Three choices are visible here. The counter lives in the shared store under a key derived from the caller, so two hosts behind one key share one window. The refusal carries a reset timestamp computed from the oldest entry in the window rather than from the moment of refusal, which is the difference between a client backing off usefully and a client retrying in a loop. And the whole function degrades rather than throws when the store does not answer, admitting the call instead of failing it — an availability choice that a spec reader has to make explicitly, because the document will not make it for them. Whatever you choose, publish the refusal shape: retry_after, limit, remaining and reset are the fields a well-behaved client will read.

The setup quota: one config file per host, and no more

Reading a specification is cheap; the quota it implies is not. Every host your tool supports costs a configuration file, a restart and a place to get the header wrong, and the document describes none of it. The client side of that quota is one function:

# 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";
  }
}

Six platforms and a default, each mapped to the file that host actually reads. The value of deriving this instead of documenting it is that the quota stops growing with your documentation: adding a platform is one case, and the docs, the installer and the audit label all move together. For a practitioner reading the specification, the honest summary is that the protocol introduces no configuration at all — the file names, the merge rules and the restart requirements are all host behaviour, and they are where integration time is actually spent.

A hello world client block: the chapter most readers skip

The quickstart every HTTP specification should have, translated into this protocol's vocabulary, is a single JSON object. It is the last chapter most readers look at and the first one their users need:

# 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,
  );
}

The generated block is as small as the protocol allows: a server map with one entry, a transport type, a URL and a header built from a placeholder plus a platform identifier. Two sentences describe everything else. The type field is where the transport choice from the first chapter becomes a literal string a host understands, and the URL is the one POST endpoint the server mounted. Read side by side with the document's initialize example, the two are the same handshake at two levels: one shows the JSON-RPC exchange, the other shows the file that makes it happen.

What an mcp server must hold between calls

One question decides whether a server needs shared state, and the document answers it only implicitly: can two calls from the same caller land on different processes? If they can, every counter the server keeps has to live outside the process:

# lib/redis/config.ts — source lines 26–29 (isTcpRedisConfigured)
function isTcpRedisConfigured(): boolean {
  const url = redisUrl();
  return Boolean(url && (url.startsWith("redis://") || url.startsWith("rediss://")));
}

Four lines that ask a narrow question — is there a TCP-speaking store configured, on one of the two connection schemes — and return a boolean instead of raising. The narrowness is deliberate: "nothing is configured" and "something is configured in a shape this path cannot use" are different states, and telling them apart is what makes an operational failure diagnosable. The specification never mentions counter stores, and this is the layer where that silence becomes somebody's design decision — one that shows up as a per-key limit that works until the process count changes.

None of it is in the model context protocol text, which is the honest way to read this walkthrough: the protocol says what a message means, not what a deployment has to remember between two of them.


How SmartGate compares

What it gives you Where it lives in the document What you pay for it
Specification + a local stdio server The full protocol surface, one transport, one client Message layer, transport, features — all normative Your implementation time, and a process per host
Specification + a hand-rolled HTTP layer The same protocol, plus whatever session and error rules you invent The document stops at the transport; the rest is yours Every decision the document left open, including the error shapes
A hosted MCP endpoint — SmartGate's seven smart_* tools One POST endpoint, a stable tool list, generated per-host config Outside the normative core by design Metered per call: 2M tokens a month on the free tier, 120 MCP requests a minute per key
Gateway on top of the hosted endpoint Bearer keys, per-key and per-team limits, budget caps, and an audit row per call Nowhere — it is the layer the document does not define The platform fee, plus a measured share once savings clear the threshold

The reading that saves the most time: the specification is complete for a client and a server on one machine, and it is silent about everything an operator needs once there are several callers. That is not a gap to complain about — it is the boundary of the document, and knowing where it sits is what makes the walkthrough useful.

Where that boundary sits is also what separates reading the document from running the surface: a mcp vs rest comparison starts where this page stops. The same question returns with the a2a protocol, where two agents rather than a client and a server have to agree on who holds session state.

How to get started

  1. Read the transport chapter before the feature chapters. Everything else is defined in terms of what the transport guarantees; the version and transport history is the shortest way to see why.
  2. Implement initialize and echo the version pair into your logs. A handshake failure with the pair printed is a five-minute problem; without it, it is a session you cannot see.
  3. Implement tools/list and one tool, then point a client at the endpoint using the connect page. A server that implements only tools is conforming.
  4. Define your error shapes. Rate limits, retries and revocation are outside the document; the MCP server page covers the endpoint side of the same choices.
  5. Read the tool catalogue in the MCP tools reference once your own tools/list works, so you can see what a matured surface looks like.

Start on the free tier — 2M tokens a month, all seven tools, 120 MCP requests a minute per key — and compare retention and limits on the pricing page.

Frequently Asked Questions

Limitations and what this does not do

  • This is a walkthrough of one implementation's reading, not the specification itself. Where the two disagree, the dated document wins; the excerpts here show what a production server chose.
  • It describes a server, not a library. Nothing here is a client SDK, and the compatibility patches quoted above are server-side workarounds for client behaviour the document permits.
  • These are windows, not whole files. Two excerpts are windows into longer files — the platform hints and the tool registry — so the branches between them are described rather than quoted.
  • The specification moves between revisions. Feature chapters are added and transports are deprecated; read the dated revision you implement rather than this page.
  • Operational guarantees are outside the document. Retention, audit completeness and limit enforcement are properties of a deployment, and no walkthrough of the specification can promise them.
  • It says nothing about model quality. The protocol carries tool calls; it does not judge whether the tool was the right one to call.

Sources

Method note

The code in this article is not transcribed. Each block was cut directly out of the slice body returned by the SmartGate slice API and re-asserted byte-for-byte as a substring of that body before publication; the first line inside every fence records the file and the exact source lines. Symbols were pinned by whole-name containment (rule A level 2) and confirmed by the service's slot-proof endpoint before the prose was written. Ten excerpts are whole slice bodies; getMcpPlatformHints keeps the shared documentation link and the generic branch, and register_mcp_tools is represented by its shortest complete tool declaration, smart_dedup. All twelve seeded sections pinned a slice, so no section on this page is written from anything other than quoted code.

Demand figures come from this project's own keyword run, recorded in research_brief.md and search_volume.json — the section phrases mcp server (60,500), mcp tools (2,400), model context protocol (12,100), mcp capabilities (90), mcp version (70), mcp token usage (20), mcp hello world (20), mcp simulator (10), mcp handshake (10), mcp rate limiting (10), mcp quota (10) and mcp bearer token (10) — and the head term mcp specification was measured at 1,300 monthly searches in the same pass.

Slice provenance

# SERP keyword Symbol File Source lines How it was pinned sha256(12)
1 model context protocol mount_mcp_routes backend/smartgate/api/mcp.py 399–406 rule A L2 → slot-proof e66f6b69a172
2 mcp capabilities infer_transport backend/smartgate/core/audit_enrichment.py 29–32 rule A L2 → slot-proof fc93297c9232
3 mcp version apply_mcp_session_compat backend/smartgate/api/mcp_session_compat.py 89–103 rule A L2 → slot-proof 2aec583c6238
4 mcp handshake getMcpPlatformHints lib/connect/mcp-config-templates.ts 313–320, 442–461 rule A L2 → slot-proof 097aa9ab5578
5 mcp tools register_mcp_tools backend/smartgate/api/mcp.py 176–196 rule A L2 → slot-proof 9d4a1623b28c
6 mcp simulator smart_fetch backend/smartgate/api/mcp.py 109–126 rule A L2 → slot-proof fdc9a4259783
7 mcp token usage validateApiKey lib/api-keys/index.ts 92–118 rule A L2 → slot-proof eea805141bb6
8 mcp bearer token hmacSha256Hex lib/crypto.ts 57–69 rule A L2 → slot-proof 0f086a9df54e
9 mcp rate limiting rateLimit lib/rate-limit.ts 20–60 rule A L2 → slot-proof e0da47919092
10 mcp quota mcpConfigFilename lib/connect/mcp-config-templates.ts 249–264 rule A L2 → slot-proof d60503ebfb68
11 mcp hello world buildGenericMcpConfigJson lib/connect/mcp-config-templates.ts 161–181 rule A L2 → slot-proof 51f127a16fc8
12 mcp server isTcpRedisConfigured lib/redis/config.ts 26–29 rule A L2 → slot-proof 7ca086a475d5

Every fenced block above was cut from the slice body and re-asserted against it byte-for-byte before publication. 12 of 12 sections pinned, 0 abstentions, 0 misses.