Chapter 10

Write Your MCP Client and Do Discovery

The client connects to one server, negotiates the protocol revision, gets the capability lists, and only then calls methods. The order here is not a formality: if you hardcode the tool list, you lose the main value of MCP - the ability to work with servers you didn't see at compile time - and at the same time create a hidden incompatibility that surfaces on someone else's server.

The client's whole path is a short sequence.

  1. Connect
  2. Negotiate era
  3. Discover
  4. Call
  5. Validate
  6. Close

And here is that same path in code: connect, discovery, read a resource, call a tool.

TypeScript
  const client = new Client(
    { name: "developer-release-client", version: "1.0.0" },
    {
      versionNegotiation: { mode: "auto" },
      enforceStrictCapabilities: true,
      defaultCacheTtlMs: 5_000
    }
  );

  try {
    await client.connect(transport);

    const [{ tools }, { resources }, { prompts }, policy, review] = await Promise.all([
      client.listTools(),
      client.listResources(),
      client.listPrompts(),
      client.readResource({ uri: "runbook://release-policy" }),
      client.callTool({
        name: "review_release",
        arguments: { service: "web-portal", version: "2.4.0" }
      })
    ]);

    const policyTitle = resources.find((resource) => resource.uri === "runbook://release-policy")?.title
      ?? "Release policy";

    return {
      era: client.getProtocolEra(),
      tools: tools.map((tool) => tool.name),
      resources: resources.map((resource) => resource.uri),
      prompts: prompts.map((prompt) => prompt.name),
      decision: readDecision(review.structuredContent),
      policyTitle: policy.contents.length > 0 ? policyTitle : "missing"
    };
  } finally {
    await client.close();
  }

And here is what the demo actually prints - the negotiated revision, the found tools, resources, and prompts, and the release decision.

JSON
{
  "era": "modern",
  "tools": [
    "search_runbooks",
    "review_release"
  ],
  "resources": [
    "runbook://release-policy",
    "release://web-portal/2.4.0",
    "release://payments-api/1.8.0"
  ],
  "prompts": [
    "prepare-release-review"
  ],
  "decision": "ready",
  "policyTitle": "Release policy"
}

A durable pipeline sits behind this, worth repeating in any client.

  • Create the transport and the protocol client.
  • Connect and check the negotiated revision.
  • Get the server's advertised capabilities.
  • Request the lists, respecting pagination, ttlMs, and cacheScope.
  • Show the model or the user only the permitted subset.
  • Validate the result and close the connection in finally.

Two rules are easy to miss but fundamental. The first separates visibility from rights.

Discovery is not authorization. A tool appearing in the list doesn't mean a specific user has the right to call it with any arguments. The host may hide a capability from the UX, but the server must check access on every call.

The second explains why the server in the previous chapter returned tools in a stable order.

A deterministic order helps the cache. The 2026-07-28 revision recommends returning tools in a stable order. This reduces diff noise and improves reuse of the model prompt cache.

The client can find and call - now let's design the capabilities themselves so they're safe to hand to the model. We'll start with tools.

Links