SmartGateSmartGate

Model Context Protocol Example: A JSON-RPC Round Trip

A Model Context Protocol example is five JSON-RPC 2.0 messages: an initialize request, the server's initialize result with its revision and capabilities, a notifications/initialized note, a tools/call request and its result. Each carries a version field, and a failure arrives as an error object inside an otherwise successful response rather than as an HTTP status code.

Short answer: A Model Context Protocol example is five JSON-RPC 2.0 messages: an initialize request, the server's initialize result with its revision and capabilities, a notifications/initialized note, a tools/call request and its result. Each carries a version field, and a failure arrives as an error object inside an otherwise successful response rather than as an HTTP status code.

Key takeaways

  • Five shapes cover a whole session. One handshake pair, one notification, and one request and result per tool call. A client that logs those five can explain almost any failure without a debugger.
  • The id is the only correlation key. It is chosen by the client, echoed by the server, and it is what pairs a result with the request that asked for it — including when several calls are in flight at once.
  • Capabilities are negotiated, never assumed. The server answers with the capabilities it really has, so a client that offers sampling and hears nothing back must not use it.
  • An error is data, not a status. The transport can answer successfully while the payload carries an error object with a numeric code, a message and optional structured detail.
  • The revision is a date. A version string names a dated snapshot of the specification, and the server may answer with an older revision it still supports — read the result, then adapt.

A retrieval-augmented example: the messages of one session

The shortest complete exchange is five messages, and each one has a fixed shape. A client opens with an initialize request that names the JSON-RPC version, the protocol revision it wants and the capabilities it supports — an empty object is legal and means "none of the optional ones":

{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"example-client","version":"1.0"}}}

The server answers on the same id with the revision it will actually use and its own capability map. The client then sends notifications/initialized — a message with no id, because nothing answers it — and the session is live. A tool call is a second round trip: the client names a tool and passes an arguments object, and the result comes back inside result.content as a list of typed parts:

{"jsonrpc":"2.0","id":2,"result":{"content":[{"type":"text","text":"hello"}],"isError":false}}

Log five fields — id, method, params, result, error — and any session can be reconstructed after the fact. what is the Model Context Protocol places this exchange in the wider protocol, naming included. Notice what the example does not need: no session cookie, no mandated header beyond the transport's content type, and no ordering guarantee between a result and a later notification.

An open-source example: reading a document by key

The read half of a session is worth reading in real code, because example pages tend to show a pretty payload and hide the lookup. This stored-source class does the opposite: it takes an address, asks a key-value store for it, and returns either the parsed document or nothing.

# lib/pseo/sources/redis-source.ts — source lines 14–54 (RedisPseoSource)
class RedisPseoSource implements PseoSource {
  async getPage(urlPath: string): Promise<PseoPageData | null> {
    try {
      const redis = getRedis();
      const raw = await redis.get<string>(pageKey(urlPath));
      if (!raw) return null;
      return JSON.parse(raw) as PseoPageData;
    } catch {
      return null;
    }
  }

  async getIndex(indexKey: string): Promise<PseoIndex | null> {
    try {
      const redis = getRedis();
      const raw = await redis.get<string>(indexKey);
      if (!raw) return null;
      return JSON.parse(raw) as PseoIndex;
    } catch {
      return null;
    }
  }

  async validateItems(items: PseoIndexItem[], kind?: string): Promise<PseoIndexItem[]> {
    if (!items || items.length === 0) return [];
    const redis = getRedis();
    const results = await Promise.allSettled(
      items.map(async (item) => {
        const path = kind ? pseoUrlPath(kind, item.slug) : `/${item.slug}`;
        const exists = await redis.exists(pageKey(path));
        return { item, exists: exists === 1 };
      }),
    );
    return results
      .filter(
        (r): r is PromiseFulfilledResult<{ item: PseoIndexItem; exists: boolean }> =>
          r.status === "fulfilled" && r.value.exists,
      )
      .map((r) => r.value.item);
  }
}

Three decisions carry straight over to any resource reader. A miss returns null instead of raising, so "the caller asked for something that does not exist" stays separate from "the store is unreachable" — two different answers that a single exception would merge. Parsing happens on the way out, so a caller never receives an unparsed string and never has to know the storage format. And a batch of index items is filtered against the store in one pass, which is what stops a listing from advertising an address that is not actually there. The same three rules apply to a resources/read: a resource, an empty result and a transport error are three different messages. The vocabulary the protocol builds on them is defined in the specification itself.

Memory in the example: the call that changes stored state

A read is easy to make idempotent; a write is where an example stops being a diagram. On the wire the method does not change — it is still tools/call — but the tool name and the arguments object do, and the server is now expected to know whose record it is touching. That identity is not in the protocol. The initialize request carries no user, and the specification says nothing about who a call belongs to, so every example that writes memory invents that decision: a user_id field inside arguments, or a binding made at the transport and never stated in the payload.

The consequence is worth stating plainly. Two byte-identical tools/call requests can mean different things on different days — the first creates a record, the second updates the same one — because the intent lives in the arguments rather than in the method. A client that retries a write blindly is the classic way to get this wrong, and the fix is a write path that is safe to replay. The transport side of that decision is covered under protocol versions and transports.

A Cloudflare-hosted example: validating a call's arguments

A server receives arguments as an untyped JSON object, and the first thing that has to happen to it looks exactly like this model: one enumerated field, a length cap on free text, a bounded result count, and a threshold with a range.

# backend/smartgate/modules/memory/models.py — source lines 5–13 (MemoryRequest)
class MemoryRequest(BaseModel):
    action: str = Field(..., pattern="^(add|search|get|delete|history)$")
    text: Optional[str] = Field(default=None, max_length=5000)
    messages: Optional[List[Dict[str, str]]] = None
    query: Optional[str] = None
    memory_id: Optional[str] = None
    user_id: Optional[str] = None
    top_k: int = Field(default=10, ge=1, le=100)
    threshold: float = Field(default=0.1, ge=0.0, le=1.0)

Read the caps as the part of the contract a client should be able to predict. Free text stops at 5,000 characters, top_k sits between 1 and 100, and the similarity threshold between 0.0 and 1.0 — bounds that turn a bad request into a sentence about the request instead of a stack trace from deep inside a vector store. The pattern is transport-independent: whether the call arrived at an edge worker, a container or a laptop, the same model decides whether it is legal, and the handler only ever sees arguments that passed. When you write an example server, publish those bounds in the tool's inputSchema as well as enforcing them in code, or the first caller who reads only the schema will be surprised. The lifecycle that surrounds validation is in the specification walkthrough. Where that server runs — a spawned process or a hosted endpoint — changes the transport decisions around the same validation, which is the same boundary the server page draws from the operator side.

Memory for an AI agent: how a failed call is shaped

An example that only shows the happy path teaches half the protocol. Failures travel inside a successful response: the body carries an error object with a numeric code, a message and optional data, and the transport status stays successful because the request itself was understood. A server built as a web application usually has a second, richer error vocabulary underneath — plan limits, disabled features, missing fields — and something has to translate it into that one envelope.

# lib/settings/assert-plan-allows.ts — source lines 91–125 (planErrorResponse)
function planErrorResponse(err: unknown) {
  if (err instanceof PlanFeatureDisabledError) {
    return {
      status: 403,
      body: {
        error: err.message,
        code: "plan_feature_disabled",
        requiredPlan: err.requiredPlan,
        feature: err.feature,
      },
    };
  }
  if (err instanceof ExceedsPlanLimitError) {
    return {
      status: 400,
      body: {
        error: err.message,
        code: "exceeds_plan_limit",
        field: err.field,
        max: err.max,
        plan: err.plan,
      },
    };
  }
  if (err instanceof MemberToolOverridesDisabledError) {
    return {
      status: 400,
      body: {
        error: err.message,
        code: "member_tool_overrides_disabled",
      },
    };
  }
  return null;
}

This resolver is that translation table. Each internal error becomes a status plus a body with a machine-readable code and the fields that explain it, such as the required plan, the field that failed and its maximum, and anything unrecognised returns null so the caller falls back to a generic message instead of inventing one. An MCP server wants the same split: keep the internal types for logs and metrics, and map each one to a code a client can branch on. A client, meanwhile, should never branch on the human message; it is prose, it is written for a person, and it will change between releases. standing up an MCP server works through the same translation on the server side.

Agentic error handling: the string a client prints

The gap between what a server sends and what a person reads is one function wide, and an agent that gets it wrong produces a transcript nobody can act on. This helper takes a raw error and returns a single line — a title, a period, then the message — so every surface that displays an error has one shape to render.

# lib/dashboard/friendly-errors.ts — source lines 135–141 (resolveDashboardErrorMessage)
function resolveDashboardErrorMessage(
  raw: string | undefined | null,
  context?: Parameters<typeof resolveDashboardError>[1],
): string {
  const { title, message } = resolveDashboardError(raw, context);
  return `${title}. ${message}`;
}

Two choices generalise. The return type is always a non-empty string, so a caller cannot accidentally print an empty value where an error belongs, which is the difference between a readable line and a blank panel. And the raw error is handed to a second function that knows the domain, which keeps display logic out of the message resolver. An MCP client should apply the same rule in the other direction: log the structured error object, print the sentence. An agentic loop that feeds the printed sentence back to the model is usually fine, because models read prose well; a loop that feeds back an empty string will retry the same call until something stops it. The list of calls a host can make is in the tools reference.

OpenRouter-style clients: reading an error body

Clients disagree about what an error body looks like, and an example that assumes one vendor's shape will misread another's. This helper treats the body as unknown and pulls a message out of whichever form it finds — a top-level message field, a plain string error, or an object error with a message inside — and returns nothing when none of them is present.

# lib/dashboard/logs-scope-ux.ts — source lines 35–46 (logsFetchErrorMessage)
function logsFetchErrorMessage(
  json: { message?: string; error?: LogsFetchErrorBody },
): string | undefined {
  if (json.message) return json.message;
  const err = json.error;
  if (!err) return undefined;
  if (typeof err === "string") return err;
  if (typeof err === "object" && typeof err.message === "string") {
    return err.message;
  }
  return undefined;
}

The order of the checks is the useful part, because it encodes what a caller may trust. A plain message field wins because it is the friendliest shape. A bare string error is accepted because enough gateways return one. A nested object is inspected but never assumed, and an empty result is a legal outcome the interface has to handle rather than a crash. When you point a client at a hosted service — an OpenRouter-compatible router, a self-hosted proxy, a gateway — capture one real error response from it before writing the parser. Every provider's error body is a small dialect, and the parser is the one place where guessing costs you the whole error. Comparing that against a live handshake is easiest with an inspector session.

What an LLM gateway adds to the example: one POST path

The wire side of an example is a POST, and it is worth seeing how little a client needs: a URL, a JSON body, and headers. This instrumented call is the version used to exercise a round trip, because it counts the requests it makes and records the correlation identifiers it has seen.

# backend/tools/host_platform_scenario_test.py — source lines 132–139 (post_json)
def post_json(
        url: str, body: dict, extra_headers: dict[str, str], timeout: float
    ) -> tuple[int, float, str]:
        nonlocal post_count
        post_count += 1
        if correlation_id:
            correlation_ids_seen.add(correlation_id)
        return _post_json(url, body, extra_headers, timeout)

The three values it returns — status, elapsed time and response text — are the minimum evidence that a round trip happened, and they answer the three questions an integration asks in order. Did the request arrive? Status says. Is it slow? Elapsed says. Does the body look like what the documentation promised? The text says. That is the whole case for putting a gateway in front of a hosted server: the same three values arrive with a credential attached, so a log can say which caller produced them, and a rate limit or a budget is checked before the request leaves rather than reconstructed from logs afterwards. For an example page the lesson is to show the call and its timing, not only the payload — MCP against A2A makes the same point about where a request stops being a request and becomes a delegated task.

How SmartGate compares

The choice is not between protocols. It is about where the identity, the limit and the log live once a session leaves one laptop.

What it runs What a client sends What the operator gets
A local stdio server one process on one machine, spawned by the client a spawn command in the client's configuration the process log, and nothing durable
A self-hosted HTTP endpoint your own service on your own host a URL, a content type and a header whatever you remembered to instrument
A hand-rolled client your code against somebody else's server your retries, timeouts and version pin your own reading of five message shapes
SmartGate a hosted MCP endpoint with seven tools over Streamable HTTP, one POST path one server entry, one key, one request an audit row per call, per-key rate limits and a hard budget guard

For an example page the difference shows up in what you can prove afterwards. A local server can demonstrate the handshake and nothing else; a shared endpoint lets you replay a call, find its audit row, and see the limit that applied. what a gateway adds is the longer version of that argument, and the free tier carries 2 million tokens a month across all seven tools with no card.

How to get started

  1. Send one handshake by hand. Post an initialize request to the endpoint with a client that prints the raw body, and keep the result: the revision and the capability map are the reference every later failure is compared against.
  2. Follow it with the notification. An initialize that is never followed by notifications/initialized is the most common reason a server appears to ignore the session.
  3. Call one read-only tool. A successful tools/call with no error object proves the credential and the transport at once, and it is the fastest way to find out that the server half is running at all.
  4. Break it on purpose. Send an unknown tool name and read the error object. If your client cannot print something useful from that response, fix the client before you write more tool code.
  5. Read the other primitives. Tools are only one of three lists; resources, prompts and sampling is the other half of what a session can carry.
  6. Put a limit and a budget in front before you automate. Nothing in the protocol meters a loop.

Start on the free tier — 2 million tokens a month and all seven tools — at start free; the plan limits and the endpoint shape are on the pricing page, and contract traffic starts with the contact form.

Frequently Asked Questions

What is a Model Context Protocol?

It is the protocol a client and a server speak in JSON-RPC 2.0. The client opens with an initialize request, the server answers on the same id with the revision it will use and the capabilities it actually has, and each tool call is one more round trip of request and result. A failure travels as an error object inside an otherwise successful response rather than as an HTTP status code.

Can you give me an example of an MCP server?

One is shown here from the message side: a server answers the initialize handshake with its revision and capability map, receives each tool call as an untyped arguments object, and validates it before the handler runs. It returns typed content parts in the result, and a call it cannot serve comes back as an error object with a numeric code and a message.

What are the minimum messages in an MCP session?

An initialize request, its result, an initialized notification, and then a request and result pair for each tool call. The session is live after the notification. Nothing else is mandatory, and a server that requires more is adding a rule the specification does not state.

Do I need an SDK to follow this example?

No. Every message is JSON-RPC 2.0 over a transport, so a client that can POST a JSON body and print the response can complete the handshake and call a tool. An SDK saves you the transport work and the version negotiation, but it is not what makes the messages valid.

Why did my call come back successfully with an error inside it?

Because the transport answered and the payload did not. In JSON-RPC the HTTP status describes the request itself, while an error object with a code and a message describes the operation. A client that only inspects the status will treat a failed tool call as a success.

Which protocol version should an example use?

Whatever the handshake negotiated, not whatever the client asked for. Send a revision you support, read the revision that comes back, and adapt to it. A version string is a dated snapshot rather than a compatibility promise, so logging it beside every request is cheaper than guessing later.

Where do I look when the handshake succeeds and the tool call fails?

At the arguments and the tool's input schema first, then at the credential the server actually received. A call that fails with a validation error is a client bug; a call that fails with an authorization error is almost always a header or a key that the transport dropped.

Limitations and what this does not do

  • Two of eight sections carry no code. The framing section and the write-path section matched no unique symbol, so they are written from the published specifications with no quoted implementation. Where a section shows no fence, that is the reason.
  • The payloads here are sketches, not a client library. Each inline payload names the fields that matter; it does not show every optional field, and it is not a substitute for the specification or for the transport document.
  • The quoted code is product code, not a reference server. The six excerpts come from one codebase's storage, validation, error-shaping and HTTP paths. They illustrate the rules; they are not an implementation you can copy wholesale.
  • A single example cannot show concurrency. Real clients keep several calls in flight, correlate them by id, and handle a result arriving after a cancellation. That behaviour belongs in your own test suite, not in a page.
  • This page is not the naming or the specification. the Claude-family clients covers one important client family, and the revision-by-revision detail lives in the specification rather than here.

Sources