All field notes

Model Context Protocol · TypeScript · AI agents

Build an MCP Client in TypeScript: Discovery, Tool Calls, and Trust Boundaries

Connect to a local MCP server, verify capability discovery, call a typed tool, and handle remote transports without confusing discovery with authorization.

Published
Updated
Last tested
Reading time
6 minutes

An MCP client does more than forward JSON. It owns a connection to one server, negotiates protocol capabilities, discovers primitives, executes calls, and decides which results are safe to place in model context.

This guide implements a client against the tested server from the MCP server tutorial. It then covers the decisions hidden by most quickstarts: server trust, capability changes, error handling, cancellation, and the different credential model of remote HTTP.

The host, client, and server are different roles

The host is the AI application. It may create several MCP clients, normally one per server connection. Each client speaks MCP to its server. The server connects the application to an underlying system.

AI host
  ├── MCP client ── stdio ── local filesystem server
  ├── MCP client ── HTTP  ── ticket service
  └── MCP client ── HTTP  ── analytics service

Keep these connections isolated. A malicious tool description or result from one server must not silently grant access to another server’s tools. The MCP lifecycle specification requires initialization before normal operation and defines capability negotiation; it does not say every advertised capability is trusted.

Connect to a local stdio server

The tested example compiles the server and client to ESM JavaScript. The client resolves the sibling server file, launches it as a child process, and connects:

import assert from "node:assert/strict";
import { fileURLToPath } from "node:url";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";

const serverPath = fileURLToPath(new URL("./server.js", import.meta.url));
const transport = new StdioClientTransport({
  command: process.execPath,
  args: [serverPath],
});
const client = new Client({ name: "example-client", version: "1.0.0" });

try {
  await client.connect(transport);
  // Discover and call tools here.
} finally {
  await client.close();
}

Use an absolute executable path or a controlled allowlist in a real host. Never turn a model-generated string into command or args. Launching a local server is code execution with the host’s operating-system privileges.

Discover, select, and call tools

Discovery returns schemas and descriptive metadata. The reference client makes the allowed surface observable and then calls one tool:

const tools = await client.listTools();
assert.deepEqual(
  tools.tools.map((tool) => tool.name),
  ["add"],
);

const response = await client.callTool({
  name: "add",
  arguments: { a: 20, b: 22 },
});

assert.equal(response.isError, undefined);
assert.deepEqual(response.structuredContent, { result: 42 });

The assertion is intentionally strict. In a deployed host, replace it with a reviewed policy that maps server identity to allowed tools. Store a digest of names, descriptions, and schemas and alert when they change unexpectedly. Discovery is a way to learn what the server offers, not proof that the host should expose every tool to a model.

The official TypeScript client guide also exposes listResources, readResource, listPrompts, and getPrompt. Only request a primitive after checking that the initialized server advertised the corresponding capability.

Validate results again at the host boundary

The server declares an output schema, but the host should validate fields that control downstream behavior. This is especially important if a result becomes:

  • another tool’s arguments;
  • persistent memory;
  • rendered HTML or Markdown;
  • a database query fragment; or
  • a decision to perform a write.

Treat text, embedded resources, and structured objects as untrusted data. A tool result may include an indirect prompt injection even when the server itself is honest because the server fetched hostile content.

Do not concatenate a result into system instructions. Mark its source and trust level, bound its size, and pass it as data. If another action follows, authorize that action against the original user request and current identity—not against instructions found in the tool result.

Handle failures by layer

Your client should distinguish:

FailureExampleClient response
TransportChild exits, HTTP disconnectsReconnect only within a bounded policy
ProtocolInitialization or message is invalidClose and surface an incompatibility
Tool resultisError is trueShow or reason over the safe tool error
Host validationResult violates expected shape or sizeReject it and record a policy event
AuthorizationUser cannot invoke the toolDo not call the server

A timeout is ambiguous for side effects. Do not automatically repeat a write unless the tool contract supports an idempotency key or a status lookup can reconcile the first attempt.

Use cancellation for abandoned reads and long-running operations where the server supports it. Put a total deadline around the workflow as well as individual calls. Retrying forever is a cost and availability failure, not resilience.

Connect to a remote server

For a remote endpoint, the SDK provides StreamableHTTPClientTransport:

import { StreamableHTTPClientTransport } from
  "@modelcontextprotocol/sdk/client/streamableHttp.js";

const transport = new StreamableHTTPClientTransport(
  new URL("https://tools.example.com/mcp"),
);
const client = new Client({ name: "example-host", version: "1.0.0" });

await client.connect(transport);

This is the transport skeleton, not a complete security configuration. Remote authentication belongs in a credential provider or transport integration outside model context. The MCP authorization specification builds on OAuth-related standards for HTTP transports; use its discovery and resource-indicator rules instead of inventing bearer-token exchange in prompts.

For remote connections:

  • require HTTPS and validate the intended endpoint;
  • never accept the server URL from an untrusted model result without an allowlist;
  • store tokens in a secret manager, not conversation history;
  • scope tokens to the MCP server and minimum permissions;
  • bind server identity to the allowed tool policy;
  • cap redirects and revalidate every redirect target;
  • set connection, request, and total workflow deadlines; and
  • redact authorization headers, arguments, results, and session identifiers in logs.

Make capability changes reviewable

MCP supports notifications when some lists change. Dynamic discovery is convenient, but production hosts should decide what happens when a server adds or modifies a tool.

A conservative flow is:

  1. connect and fetch capabilities;
  2. normalize the tool metadata and compute a digest;
  3. compare it with the last approved snapshot;
  4. automatically accept non-security metadata only if policy allows;
  5. quarantine new or widened tools for review; and
  6. expose only the approved subset to the model.

This protects against a “rug pull” in which a previously benign server changes its tool description or schema after installation. OWASP’s MCP security guidance calls out tool poisoning, shadowing, cross-server attacks, and supply-chain risk explicitly.

Client acceptance tests

Test the client with a real server process, not only mocked method calls. Cover:

  • successful initialization and shutdown;
  • the exact approved discovery surface;
  • a valid tool call and structured result;
  • invalid input rejected before business logic;
  • isError results;
  • server exit during a call;
  • an oversized or malicious result;
  • cancellation and deadline expiry;
  • a tool-list change; and
  • an ambiguous write followed by reconciliation.

The reference example verifies initialization/shutdown, the exact discovery surface, a valid structured result, and an expected numeric-overflow tool error. Extend the same harness with your application’s transport-failure and authorization rules before calling it production-ready.

MCP connects a host to capabilities. If your system also needs independent agents to discover each other and exchange stateful tasks, read MCP vs. A2A before stretching one protocol into the other’s job.

Sources and freshness notes

This guide was checked on July 18, 2026 against the MCP lifecycle, transport, and authorization specifications plus the official TypeScript client documentation. Re-run the repository check after any SDK upgrade.