Skip to content

Writing a Client

Both official SDKs give you the same shape: construct a client with a name and version, pick a transport, connect, and then call methods that map one-to-one onto protocol requests (tools/list, tools/call, resources/read, prompts/get). The SDK fills in _meta — protocol version, your declared capabilities, your identity — on every request.

sequenceDiagram
  participant H as Host code
  participant C as Client (SDK)
  participant S as Server
  H->>C: connect(transport)
  C->>S: server/discover
  S-->>C: supportedVersions, capabilities
  H->>C: listTools()
  C->>S: tools/list (+ _meta)
  S-->>C: tools[], ttlMs
  H->>C: callTool(name, args)
  C->>S: tools/call (+ _meta)
  S-->>C: content[], isError
What one client does
Terminal window
npm install @modelcontextprotocol/client

For a local server the client launches the process itself and talks over its standard streams.

import { Client } from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';
const client = new Client({ name: 'my-first-client', version: '1.0.0' });
const transport = new StdioClientTransport({
command: 'npx',
args: ['tsx', 'src/index.ts'],
});
await client.connect(transport);

The client owns the subprocess: it closes the server’s stdin on shutdown and restarts the process if it exits unexpectedly (in-flight requests are simply retried, because the protocol is stateless).

For a remote server you give the client the MCP endpoint URL. Each request becomes its own HTTP POST with the required headers; the SDK handles JSON versus SSE responses for you.

import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
const client = new Client({ name: 'my-client', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'));
await client.connect(transport);

If the server needs a bearer token, give the transport a provider that returns the current token (TypeScript authProvider: { token: async () => ... }; Python passes an httpx2.AsyncClient(auth=...) into streamable_http_client(url, http_client=...)). The full OAuth flow is covered in the security module.

listTools returns the tool definitions exactly as the server describes them: name, optional title, description, inputSchema, optional outputSchema and annotations. callTool sends the arguments and returns the result.

const { tools } = await client.listTools();
for (const tool of tools) console.log(tool.name, tool.description);
const result = await client.callTool({
name: 'calculate-bmi',
arguments: { weightKg: 70, heightM: 1.75 },
});
// Tool-level errors are returned in the result, not thrown
if (result.isError) {
console.error('Tool error:', result.content);
return;
}
console.log(result.content); // content blocks
console.log(result.structuredContent); // present if the tool has an outputSchema

The distinction between the two error channels is a design decision you must respect in a host:

  • Protocol errors (unknown tool, malformed request) come back as JSON-RPC errors. The SDK raises them. The model is unlikely to fix these; log them and tell the user.
  • Tool execution errors (“date must be in the future”) come back as a normal result with isError: true. Do not throw them away — feed the text back to the model so it can correct itself and retry.
const { resources } = await client.listResources();
const read = await client.readResource({ uri: 'file:///project/README.md' });
for (const c of read.contents) {
if ('text' in c) console.log(c.text);
}
const prompt = await client.getPrompt({
name: 'review-code',
arguments: { language: 'typescript' },
});
console.log(prompt.messages);

Remember the interaction models from the server primitives module: tools are for the model to call, resources are for the application to attach as context, prompts are for the user to pick. A client exposes all three; the host decides how each surfaces in the UI.

You do not need a network or a subprocess to test a server. Both SDKs let a client talk to a server object directly, which makes unit tests fast and deterministic.

import { Client, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';
import { createMcpHandler, McpServer } from '@modelcontextprotocol/server';
const handler = createMcpHandler(() => {
const server = new McpServer({ name: 'pricing', version: '1.0.0' });
// ...registerTool(...)
return server;
});
// Route the transport's fetch straight into the handler — no port opened.
const transport = new StreamableHTTPClientTransport(new URL('http://test.local/mcp'), {
fetch: (url, init) => handler.fetch(new Request(url, init)),
});
const client = new Client({ name: 'test', version: '0.0.0' });
await client.connect(transport);

Under the 2026-07-28 protocol a server never sends its own requests to the client. When a tool needs user input it returns an InputRequiredResult; the SDK client resolves the embedded elicitation/create request through a handler you register, then retries the original call with inputResponses and the opaque requestState. See the client interaction module for the wire details.

client.setRequestHandler('elicitation/create', async (request) => {
if (request.params.mode === 'url') {
// Show the full URL, get consent, open it in the system browser.
return { action: 'accept' };
}
// Render request.params.requestedSchema as a form; return what the user entered.
return { action: 'accept', content: { city: 'Lisbon' } };
});

Declaring the handler is also what makes the SDK advertise elicitation in clientCapabilities on each request. A server must not send an input request for a capability you did not declare.

  • Keep the client thin. Routing, namespacing, consent, and caching are host concerns. If your client class grows a “which server?” branch, you have put host logic in the wrong layer.
  • Treat every result as untrusted input. Tool text, resource contents, and annotations come from the server. Validate before showing to a user or feeding to a model.
  • Set timeouts. A tools/call can hang. Bound it, and surface a clear error rather than freezing the conversation.
  • Cache the discovery result. server/discover and tools/list carry ttlMs; reuse them until they expire or a list_changed notification arrives.
How does the TypeScript client report a tool execution error such as "invalid date"?
Which transport does a Python Client use when you pass it a plain URL string?
What is the point of passing the MCPServer object directly to Client(mcp) in Python?
Under the 2026-07-28 protocol, how does a server obtain user input during a tool call?