Skip to content

Latest commit

 

History

History
859 lines (674 loc) · 42.8 KB

File metadata and controls

859 lines (674 loc) · 42.8 KB
title Client Guide

Building MCP clients

This guide covers the TypeScript SDK APIs for building MCP clients. For protocol-level concepts, see the MCP overview.

A client connects to a server, discovers what it offers — tools, resources, prompts — and invokes them. Beyond that core loop, this guide covers authentication, error handling, and responding to server-initiated requests like sampling and elicitation.

Imports

The examples below use these imports. Adjust based on which features and transport you need:

import type {
    AuthProvider,
    OAuthClientInformationContext,
    OAuthClientInformationMixed,
    OAuthClientMetadata,
    OAuthClientProvider,
    OAuthDiscoveryState,
    OAuthTokens
} from '@modelcontextprotocol/client';
import {
    applyMiddlewares,
    Client,
    ClientCredentialsProvider,
    createMiddleware,
    CrossAppAccessProvider,
    discoverAndRequestJwtAuthGrant,
    IssuerMismatchError,
    PrivateKeyJwtProvider,
    ProtocolError,
    SdkError,
    SdkErrorCode,
    SSEClientTransport,
    StreamableHTTPClientTransport,
    TRACEPARENT_META_KEY,
    TRACESTATE_META_KEY,
    UnauthorizedError
} from '@modelcontextprotocol/client';
import { StdioClientTransport } from '@modelcontextprotocol/client/stdio';

Connecting to a server

Streamable HTTP

For remote HTTP servers, use {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}:

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);

For a full interactive client over Streamable HTTP, see repl/client.ts.

stdio

For local, process-spawned servers (Claude Desktop, CLI tools), use {@linkcode @modelcontextprotocol/client!client/stdio.StdioClientTransport | StdioClientTransport}. The transport spawns the server process and communicates over stdin/stdout:

const client = new Client({ name: 'my-client', version: '1.0.0' });

const transport = new StdioClientTransport({
    command: 'node',
    args: ['server.js']
});

await client.connect(transport);

SSE fallback for legacy servers

To support both modern Streamable HTTP and legacy SSE servers, try {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport} first and fall back to {@linkcode @modelcontextprotocol/client!client/sse.SSEClientTransport | SSEClientTransport} on failure:

const baseUrl = new URL(url);

try {
    // Try modern Streamable HTTP transport first
    const client = new Client({ name: 'my-client', version: '1.0.0' });
    const transport = new StreamableHTTPClientTransport(baseUrl);
    await client.connect(transport);
    return { client, transport };
} catch {
    // Fall back to legacy SSE transport
    const client = new Client({ name: 'my-client', version: '1.0.0' });
    const transport = new SSEClientTransport(baseUrl);
    await client.connect(transport);
    return { client, transport };
}

The snippet above is the complete pattern; wrap the catch body with whatever error reporting your host needs.

Protocol version negotiation (2026-07-28 revision)

By default the client negotiates a 2025-era protocol version via the initialize handshake — exactly the v1.x behavior, byte for byte. To talk to a server on the 2026-07-28 revision, opt into version negotiation via ClientOptions.versionNegotiation:

// Auto-negotiate: probe with server/discover, fall back to the 2025 handshake
// against a 2025-only server.
const client = new Client({ name: 'my-client', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await client.connect(transport);

client.getProtocolEra(); // 'modern' or 'legacy'
client.getNegotiatedProtocolVersion(); // '2026-07-28' or '2025-11-25'
  • absent / mode: 'legacy' (the default) — today's 2025 connect sequence; no probe, no new headers.
  • mode: 'auto'connect() probes with server/discover; a 2025-only server rejects the probe and the client falls back to the plain initialize handshake on the same connection, byte-equivalent to a 2025 client. The probe costs one round trip against an old server.
  • mode: { pin: '2026-07-28' } — modern era at exactly that revision; no fallback. Against a 2025-only server connect() rejects with a typed error. Use pin where a silent downgrade would be worse than an error (tests, CI, servers you control).

Once a modern era is negotiated, the client automatically attaches the per-request _meta envelope (the reserved protocol-version / client-info / client-capabilities keys) to every outgoing request and notification. You can also configure negotiation pre-connect on an already-constructed instance via {@linkcode @modelcontextprotocol/client!client/client.Client#setVersionNegotiation | client.setVersionNegotiation()}. See the 2026-07-28 support guide › Probe policy for the full failure semantics and probe-timeout behavior.

Skipping the probe: connect({ prior })

A gateway, proxy, or worker fleet that already knows the server's server/discover advertisement can skip the probe entirely. Pass a previously-obtained {@linkcode @modelcontextprotocol/client!index.DiscoverResult | DiscoverResult} via {@linkcode @modelcontextprotocol/client!client/client.ConnectOptions | ConnectOptions.prior} and connect() adopts it directly with zero round trips — the 2026-07-28 protocol is stateless on HTTP, so once the advertisement is known there is nothing left to negotiate.

// Probe once (here via the 'auto'-mode connect), persist the result …
const bootstrap = new Client({ name: 'gateway', version: '1.0.0' }, { versionNegotiation: { mode: 'auto' } });
await bootstrap.connect(new StreamableHTTPClientTransport(url));
const persisted = JSON.stringify(bootstrap.getDiscoverResult());

// … then every worker connects with zero round trips.
const worker = new Client({ name: 'worker', version: '1.0.0' });
await worker.connect(new StreamableHTTPClientTransport(url), { prior: JSON.parse(persisted) });

{@linkcode @modelcontextprotocol/client!client/client.Client#getDiscoverResult | client.getDiscoverResult()} returns the value that the 'auto'/pinned probe path, an explicit {@linkcode @modelcontextprotocol/client!client/client.Client#discover | client.discover()} call, or a prior connect({ prior }) recorded; it round-trips through JSON.stringify/JSON.parse. connect({ prior }) is 2026-07-28+ only — it rejects with SdkError(EraNegotiationFailed) when the supplied result and the client share no modern revision. Only reuse a persisted DiscoverResult across clients that present the same authorization context as the one that obtained it. See the gateway/ example for the full probe-once / connect-many pattern with a server-side proof.

Disconnecting

Call {@linkcode @modelcontextprotocol/client!client/client.Client#close | await client.close() } to disconnect. Pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error.

For Streamable HTTP, terminate the server-side session first (per the MCP specification):

await transport.terminateSession(); // notify the server (recommended)
await client.close();

For stdio, client.close() handles graceful process shutdown (closes stdin, then SIGTERM, then SIGKILL if needed).

Server instructions

Servers can provide an instructions string during initialization that describes how to use them — cross-tool relationships, workflow patterns, and constraints (see Instructions in the MCP specification). Retrieve it after connecting and include it in the model's system prompt:

const instructions = client.getInstructions();

const systemPrompt = ['You are a helpful assistant.', instructions].filter(Boolean).join('\n\n');

console.log(systemPrompt);

Authentication

MCP servers can require authentication before accepting client connections (see Authorization in the MCP specification). Pass an {@linkcode @modelcontextprotocol/client!client/auth.AuthProvider | AuthProvider} to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport | StreamableHTTPClientTransport}. The transport calls token() before every request and onUnauthorized() (if provided) on 401, then retries once.

Bearer tokens

For servers that accept bearer tokens managed outside the SDK — API keys, tokens from a gateway or proxy, service-account credentials — implement only token(). With no onUnauthorized(), a 401 throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} immediately:

const authProvider: AuthProvider = { token: async () => getStoredToken() };

const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });

See simpleTokenProvider.ts for a complete runnable example.

Client credentials

{@linkcode @modelcontextprotocol/client!client/authExtensions.ClientCredentialsProvider | ClientCredentialsProvider} handles the client_credentials grant flow for service-to-service communication:

const authProvider = new ClientCredentialsProvider({
    clientId: 'my-service',
    clientSecret: 'my-secret'
});

const client = new Client({ name: 'my-client', version: '1.0.0' });

const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });

await client.connect(transport);

Private key JWT

{@linkcode @modelcontextprotocol/client!client/authExtensions.PrivateKeyJwtProvider | PrivateKeyJwtProvider} signs JWT assertions for the private_key_jwt token endpoint auth method, avoiding a shared client secret:

const authProvider = new PrivateKeyJwtProvider({
    clientId: 'my-service',
    privateKey: pemEncodedKey,
    algorithm: 'RS256'
});

const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });

For a runnable client_credentials example, see oauth-client-credentials/client.ts — its README shows the private_key_jwt swap (the in-repo demo Authorization Server only implements client_secret_basic/client_secret_post, so there is no runnable private_key_jwt leg).

Full OAuth with user authorization

For user-facing applications, implement the {@linkcode @modelcontextprotocol/client!client/auth.OAuthClientProvider | OAuthClientProvider} interface to handle the full authorization code flow (redirects, code verifiers, token storage, dynamic client registration). Key persisted client credentials by the ctx.issuer passed to clientInformation() / saveClientInformation() so credentials registered with one authorization server are never sent to another:

class MyOAuthProvider implements OAuthClientProvider {
    // Key DCR-obtained credentials by issuer so a client_id registered with one
    // authorization server is never returned for another (SEP-2352).
    private creds = new Map<string, OAuthClientInformationMixed>();
    private storedTokens?: OAuthTokens;
    private verifier?: string;
    private discovery?: OAuthDiscoveryState;
    lastState?: string;

    readonly redirectUrl = 'http://localhost:8090/callback';
    readonly clientMetadata: OAuthClientMetadata = {
        client_name: 'My MCP Client',
        redirect_uris: ['http://localhost:8090/callback'],
        // Loopback redirect → the SDK would default this to 'native'; set
        // explicitly when the heuristic is wrong for your deployment (SEP-837).
        application_type: 'native'
    };

    clientInformation(ctx?: OAuthClientInformationContext) {
        return ctx ? this.creds.get(ctx.issuer) : undefined;
    }
    saveClientInformation(info: OAuthClientInformationMixed, ctx?: OAuthClientInformationContext) {
        if (ctx) this.creds.set(ctx.issuer, info);
    }
    tokens() {
        return this.storedTokens;
    }
    saveTokens(tokens: OAuthTokens) {
        // In production, persist to OS keychain / secure storage — never plain files.
        this.storedTokens = tokens;
    }
    // CSRF binding for the redirect — the SDK puts this on the authorize URL;
    // your callback handler compares it before calling `finishAuth`.
    state() {
        this.lastState = crypto.randomUUID();
        return this.lastState;
    }
    // Callback-leg AS-binding (SEP-2352): record what discovery resolved before
    // the redirect so the SDK can verify the code is exchanged at the same AS.
    saveDiscoveryState(state: OAuthDiscoveryState) {
        this.discovery = state;
    }
    discoveryState() {
        return this.discovery;
    }
    redirectToAuthorization(url: URL) {
        onRedirect(url);
    }
    saveCodeVerifier(v: string) {
        this.verifier = v;
    }
    codeVerifier() {
        if (!this.verifier) throw new Error('no code verifier');
        return this.verifier;
    }
}

const provider = new MyOAuthProvider();
const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), {
    authProvider: provider
});

The {@linkcode @modelcontextprotocol/client!client/client.Client#connect | connect()} call throws {@linkcode @modelcontextprotocol/client!client/auth.UnauthorizedError | UnauthorizedError} when authorization is needed — catch it, complete the browser flow, hand the callback query to {@linkcode @modelcontextprotocol/client!client/streamableHttp.StreamableHTTPClientTransport#finishAuth | transport.finishAuth()}, and reconnect. Passing the whole URLSearchParams lets the SDK extract code and validate the RFC 9207 iss parameter for you:

const client = new Client({ name: 'my-client', version: '1.0.0' });
const transport = new StreamableHTTPClientTransport(url, { authProvider: provider });
try {
    await client.connect(transport);
    return client;
} catch (error) {
    // With version negotiation, the connect-time 401 may surface wrapped as
    // SdkError(EraNegotiationFailed) whose .data.cause is the UnauthorizedError.
    const root = error instanceof UnauthorizedError ? error : (error as { data?: { cause?: unknown } }).data?.cause;
    if (!(root instanceof UnauthorizedError)) throw error;
    // The transport called redirectToAuthorization(); fall through to the browser callback.
}

const callbackUrl = await waitForCallback();
const params = new URL(callbackUrl).searchParams;

// The SDK does not validate `state` — compare it to the value your provider generated.
if (params.get('state') !== provider.lastState) throw new Error('state mismatch');

try {
    // Preferred: hand over the whole query — the SDK extracts `code` and
    // `iss`, validates `iss` (RFC 9207), and never surfaces callback-derived
    // `error`/`error_description` text on mismatch.
    await transport.finishAuth(params);
} catch (error) {
    if (error instanceof IssuerMismatchError) {
        // Mix-up attack: do NOT render params.get('error_description') to the user.
        throw new Error('Authorization failed: issuer mismatch');
    }
    throw error;
}

// Reconnect on a FRESH transport — a started transport cannot be restarted;
// OAuth state (tokens, verifier, discovery) lives on the provider, not the transport.
await client.connect(new StreamableHTTPClientTransport(url, { authProvider: provider }));
return client;

For a complete working OAuth flow, see simpleOAuthClient.ts and simpleOAuthClientProvider.ts.

Cross-App Access (Enterprise Managed Authorization)

{@linkcode @modelcontextprotocol/client!client/authExtensions.CrossAppAccessProvider | CrossAppAccessProvider} implements Enterprise Managed Authorization (SEP-990) for scenarios where users authenticate with an enterprise identity provider (IdP) and clients need to access protected MCP servers on their behalf.

This provider handles a two-step OAuth flow:

  1. Exchange the user's ID Token from the enterprise IdP for a JWT Authorization Grant (JAG) via RFC 8693 token exchange
  2. Exchange the JAG for an access token from the MCP server via RFC 7523 JWT bearer grant
const authProvider = new CrossAppAccessProvider({
    assertion: async ctx => {
        // ctx provides: authorizationServerUrl, resourceUrl, scope, fetchFn
        const result = await discoverAndRequestJwtAuthGrant({
            idpUrl: 'https://idp.example.com',
            audience: ctx.authorizationServerUrl,
            resource: ctx.resourceUrl,
            idToken: await getIdToken(),
            clientId: 'my-idp-client',
            clientSecret: 'my-idp-secret',
            scope: ctx.scope,
            fetchFn: ctx.fetchFn
        });
        return result.jwtAuthGrant;
    },
    clientId: 'my-mcp-client',
    clientSecret: 'my-mcp-secret'
});

const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), { authProvider });

The assertion callback receives a context object with:

  • authorizationServerUrl – The MCP server's authorization server (discovered automatically)
  • resourceUrl – The MCP resource URL (discovered automatically)
  • scope – Optional scope passed to auth() or from clientMetadata
  • fetchFn – Fetch implementation to use for HTTP requests

For manual control over the token exchange steps, use the Layer 2 utilities from @modelcontextprotocol/client:

  • requestJwtAuthorizationGrant() – Exchange ID Token for JAG at IdP
  • discoverAndRequestJwtAuthGrant() – Discovery + JAG acquisition
  • exchangeJwtAuthGrant() – Exchange JAG for access token at MCP server

Tools

Tools are callable actions offered by servers — discovering and invoking them is usually how your client enables an LLM to take action (see Tools in the MCP overview).

Use {@linkcode @modelcontextprotocol/client!client/client.Client#listTools | listTools()} to discover available tools, and {@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} to invoke one. listTools() walks every page on your behalf and returns the complete list (pass an explicit { cursor } for per-page control):

const { tools } = await client.listTools();
console.log(
    'Available tools:',
    tools.map(t => t.name)
);

const result = await client.callTool({
    name: 'calculate-bmi',
    arguments: { weightKg: 70, heightM: 1.75 }
});
console.log(result.content);

Tool results may include a structuredContent field — a machine-readable JSON value (any JSON type per SEP-2106) for programmatic use by the client application, complementing content which is for the LLM:

const result = await client.callTool({
    name: 'calculate-bmi',
    arguments: { weightKg: 70, heightM: 1.75 }
});

// Machine-readable output for the client application. SEP-2106: structuredContent is
// `unknown` (any JSON value). Check for presence with `!== undefined` and narrow before use.
if (result.structuredContent !== undefined) {
    const sc: unknown = result.structuredContent; // e.g. { bmi: 22.86 }
    if (typeof sc === 'object' && sc !== null && 'bmi' in sc) {
        console.log(sc.bmi);
    }
}

Tracking progress

Pass onprogress to receive incremental progress notifications from long-running tools. Use resetTimeoutOnProgress to keep the request alive while the server is actively reporting, and maxTotalTimeout as an absolute cap:

const result = await client.callTool(
    { name: 'long-operation', arguments: {} },
    {
        onprogress: ({ progress, total }: { progress: number; total?: number }) => {
            console.log(`Progress: ${progress}/${total ?? '?'}`);
        },
        resetTimeoutOnProgress: true,
        maxTotalTimeout: 600_000
    }
);
console.log(result.content);

x-mcp-header parameter mirroring (2026-07-28 draft)

On a 2026-07-28 connection over Streamable HTTP, callTool() mirrors any argument whose inputSchema property carries an x-mcp-header annotation into an Mcp-Param-{Name} HTTP request header so intermediaries can route on it without parsing the body. The mirrored headers are built from the client's internal tools/list cache; if you already hold the tool definition (e.g. from configuration), pass it via CallToolRequestOptions.toolDefinition so mirroring runs without a prior list. On a cache miss the call is sent without Mcp-Param-* headers and, when a conforming server rejects it with -32020 (HeaderMismatch), callTool() refreshes the definition cache once and retries.

On a non-stdio modern connection listTools() (and the internal tools/list cache) exclude tool definitions whose x-mcp-header declarations violate the spec's constraints, logging a warning that names the tool and the reason. Browser clients skip mirroring (dynamically named headers cannot be statically allow-listed for credentialed CORS), so calling an x-mcp-header tool with a non-null designated argument from a browser against a server that enforces SEP-2243 validation will be rejected — a known limitation. The legacy-era callTool/listTools paths are unchanged.

Resources

Resources are read-only data — files, database schemas, configuration — that your application can retrieve from a server and attach as context for the model (see Resources in the MCP overview).

Use {@linkcode @modelcontextprotocol/client!client/client.Client#listResources | listResources()} and {@linkcode @modelcontextprotocol/client!client/client.Client#readResource | readResource()} to discover and read server-provided data. listResources() walks every page on your behalf and returns the complete list (pass an explicit { cursor } for per-page control):

const { resources } = await client.listResources();
console.log(
    'Available resources:',
    resources.map(r => r.name)
);

const { contents } = await client.readResource({ uri: 'config://app' });
for (const item of contents) {
    console.log(item);
}

To discover URI templates for dynamic resources, use {@linkcode @modelcontextprotocol/client!client/client.Client#listResourceTemplates | listResourceTemplates()}.

Subscribing to resource changes

If the server supports resource subscriptions, use {@linkcode @modelcontextprotocol/client!client/client.Client#subscribeResource | subscribeResource()} to receive notifications when a resource changes, then re-read it:

await client.subscribeResource({ uri: 'config://app' });

client.setNotificationHandler('notifications/resources/updated', async notification => {
    if (notification.params.uri === 'config://app') {
        const { contents } = await client.readResource({ uri: 'config://app' });
        console.log('Config updated:', contents);
    }
});

// Later: stop receiving updates
await client.unsubscribeResource({ uri: 'config://app' });

Prompts

Prompts are reusable message templates that servers offer to help structure interactions with models (see Prompts in the MCP overview).

Use {@linkcode @modelcontextprotocol/client!client/client.Client#listPrompts | listPrompts()} and {@linkcode @modelcontextprotocol/client!client/client.Client#getPrompt | getPrompt()} to list available prompts and retrieve them with arguments. listPrompts() walks every page on your behalf and returns the complete list (pass an explicit { cursor } for per-page control):

const { prompts } = await client.listPrompts();
console.log(
    'Available prompts:',
    prompts.map(p => p.name)
);

const { messages } = await client.getPrompt({
    name: 'review-code',
    arguments: { code: 'console.log("hello")' }
});
console.log(messages);

Completions

Both prompts and resources can support argument completions. Use {@linkcode @modelcontextprotocol/client!client/client.Client#complete | complete()} to request autocompletion suggestions from the server as a user types:

const { completion } = await client.complete({
    ref: {
        type: 'ref/prompt',
        name: 'review-code'
    },
    argument: {
        name: 'language',
        value: 'type'
    }
});
console.log(completion.values); // e.g. ['typescript']

Notifications

Automatic list-change tracking

The {@linkcode @modelcontextprotocol/client!client/client.ClientOptions | listChanged} client option keeps a local cache of tools, prompts, or resources in sync with the server. It provides automatic server capability gating, debouncing (300 ms by default), auto-refresh, and error-first callbacks:

const client = new Client(
    { name: 'my-client', version: '1.0.0' },
    {
        listChanged: {
            tools: {
                onChanged: (error, tools) => {
                    if (error) {
                        console.error('Failed to refresh tools:', error);
                        return;
                    }
                    console.log('Tools updated:', tools);
                }
            },
            prompts: {
                onChanged: (error, prompts) => console.log('Prompts updated:', prompts)
            }
        }
    }
);

Manual notification handlers

For full control — or for notification types not covered by listChanged (such as log messages) — register handlers directly with {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()}:

// Server log messages (sent by the server during request processing)
client.setNotificationHandler('notifications/message', notification => {
    const { level, data } = notification.params;
    console.log(`[${level}]`, data);
});

// Server's resource list changed — re-fetch the list
client.setNotificationHandler('notifications/resources/list_changed', async () => {
    const { resources } = await client.listResources();
    console.log('Resources changed:', resources.length);
});

Warning

MCP logging (including setLoggingLevel() and notifications/message) is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the deprecated features registry. Servers should migrate to stderr logging (STDIO) or OpenTelemetry.

To control the minimum severity of log messages the server sends, use {@linkcode @modelcontextprotocol/client!client/client.Client#setLoggingLevel | setLoggingLevel()}:

await client.setLoggingLevel('warning');

Warning

listChanged and {@linkcode @modelcontextprotocol/client!client/client.Client#setNotificationHandler | setNotificationHandler()} are mutually exclusive per notification type — using both for the same notification will cause the manual handler to be overwritten.

Handling server-initiated requests

MCP is bidirectional — servers can send requests to the client during tool execution, as long as the client declares matching capabilities (see Architecture in the MCP overview). Declare the corresponding capability when constructing the {@linkcode @modelcontextprotocol/client!client/client.Client | Client} and register a request handler:

const client = new Client(
    { name: 'my-client', version: '1.0.0' },
    {
        capabilities: {
            sampling: {},
            elicitation: { form: {} }
        }
    }
);

Sampling

Warning

Sampling is deprecated as of protocol version 2026-07-28 (SEP-2577). It remains fully functional during the deprecation window (at least twelve months); see the deprecated features registry. Servers should migrate to calling LLM provider APIs directly.

When a server needs an LLM completion during tool execution, it sends a sampling/createMessage request to the client (see Sampling in the MCP overview). Register a handler to fulfill it:

client.setRequestHandler('sampling/createMessage', async request => {
    const lastMessage = request.params.messages.at(-1);
    console.log('Sampling request:', lastMessage);

    // In production, send messages to your LLM here
    return {
        model: 'my-model',
        role: 'assistant' as const,
        content: {
            type: 'text' as const,
            text: 'Response from the model'
        }
    };
});

Elicitation

When a server needs user input during tool execution, it sends an elicitation/create request to the client (see Elicitation in the MCP overview). The client should present the form to the user and return the collected data, or { action: 'decline' }:

client.setRequestHandler('elicitation/create', async request => {
    console.log('Server asks:', request.params.message);

    if (request.params.mode === 'form') {
        // Present the schema-driven form to the user
        console.log('Schema:', request.params.requestedSchema);
        return { action: 'accept', content: { confirm: true } };
    }

    return { action: 'decline' };
});

For a full form-based elicitation handler with AJV validation, see repl/client.ts. For URL elicitation mode (both the 2025-era push/throw style and the 2026-07-28 inputRequired return), see elicitation/client.ts.

Roots

Warning

Roots are deprecated as of protocol version 2026-07-28 (SEP-2577). They remain fully functional during the deprecation window (at least twelve months); see the deprecated features registry. Migrate to passing paths via tool parameters, resource URIs, or configuration.

Roots let the client expose filesystem boundaries to the server (see Roots in the MCP overview). Declare the roots capability and register a roots/list handler:

client.setRequestHandler('roots/list', async () => {
    return {
        roots: [
            { uri: 'file:///home/user/projects/my-app', name: 'My App' },
            { uri: 'file:///home/user/data', name: 'Data' }
        ]
    };
});

When the available roots change, notify the server with {@linkcode @modelcontextprotocol/client!client/client.Client#sendRootsListChanged | client.sendRootsListChanged()}.

Error handling

Tool errors vs protocol errors

{@linkcode @modelcontextprotocol/client!client/client.Client#callTool | callTool()} has two error surfaces: the tool can run but report failure via isError: true in the result, or the request itself can fail and throw an exception. Always check both:

try {
    const result = await client.callTool({
        name: 'fetch-data',
        arguments: { url: 'https://example.com' }
    });

    // Tool-level error: the tool ran but reported a problem
    if (result.isError) {
        console.error('Tool error:', result.content);
        return;
    }

    console.log('Success:', result.content);
} catch (error) {
    // Protocol-level error: the request itself failed
    if (error instanceof ProtocolError) {
        console.error(`Protocol error ${error.code}: ${error.message}`);
    } else if (error instanceof SdkError) {
        console.error(`SDK error [${error.code}]: ${error.message}`);
    } else {
        throw error;
    }
}

{@linkcode @modelcontextprotocol/client!index.ProtocolError | ProtocolError} represents JSON-RPC errors from the server (method not found, invalid params, internal error). {@linkcode @modelcontextprotocol/client!index.SdkError | SdkError} represents local SDK errors — {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | REQUEST_TIMEOUT}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED}, {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.CapabilityNotSupported | CAPABILITY_NOT_SUPPORTED}, and others.

Connection lifecycle

Set {@linkcode @modelcontextprotocol/client!client/client.Client#onerror | client.onerror} to catch out-of-band transport errors (SSE disconnects, parse errors). Set {@linkcode @modelcontextprotocol/client!client/client.Client#onclose | client.onclose} to detect when the connection drops — pending requests are rejected with a {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.ConnectionClosed | CONNECTION_CLOSED} error:

// Out-of-band errors (SSE disconnects, parse errors)
client.onerror = error => {
    console.error('Transport error:', error.message);
};

// Connection closed (pending requests are rejected with CONNECTION_CLOSED)
client.onclose = () => {
    console.log('Connection closed');
};

Timeouts

All requests have a 60-second default timeout. Pass a custom timeout in the options to override it. On timeout, the SDK sends a cancellation notification to the server and rejects the promise with {@linkcode @modelcontextprotocol/client!index.SdkErrorCode.RequestTimeout | SdkErrorCode.RequestTimeout}:

try {
    const result = await client.callTool(
        { name: 'slow-operation', arguments: {} },
        { timeout: 120_000 } // 2 minutes instead of the default 60 seconds
    );
    console.log(result.content);
} catch (error) {
    if (error instanceof SdkError && error.code === SdkErrorCode.RequestTimeout) {
        console.error('Request timed out');
    }
}

Client middleware

Use {@linkcode @modelcontextprotocol/client!client/middleware.createMiddleware | createMiddleware()} and {@linkcode @modelcontextprotocol/client!client/middleware.applyMiddlewares | applyMiddlewares()} to compose fetch middleware pipelines. Middleware wraps the underlying fetch call and can add headers, handle retries, or log requests. Pass the enhanced fetch to the transport via the fetch option:

const authMiddleware = createMiddleware(async (next, input, init) => {
    const headers = new Headers(init?.headers);
    headers.set('X-Custom-Header', 'my-value');
    return next(input, { ...init, headers });
});

const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), {
    fetch: applyMiddlewares(authMiddleware)(fetch)
});

Trace context propagation

The MCP specification (SEP-414) reserves the unprefixed _meta keys traceparent, tracestate, and baggage for distributed trace context, as an exception to the usual _meta key prefix rule. When present, the values must follow the W3C Trace Context and W3C Baggage formats. The SDK does not interpret these keys — _meta passes through both directions untouched — so you can propagate OpenTelemetry context across any transport, including stdio where HTTP headers are unavailable. The key names are exported as TRACEPARENT_META_KEY, TRACESTATE_META_KEY, and BAGGAGE_META_KEY.

Attach trace context to a single request via _meta:

// Values would normally come from your tracer's active span context.
const result = await client.callTool({
    name: 'calculate-bmi',
    arguments: { weightKg: 70, heightM: 1.75 },
    _meta: {
        [TRACEPARENT_META_KEY]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01',
        [TRACESTATE_META_KEY]: 'vendor1=opaqueValue1'
    }
});
console.log(result.content);

Or inject it into every outgoing request with fetch middleware (Streamable HTTP transport):

const traceContextMiddleware = createMiddleware(async (next, input, init) => {
    if (typeof init?.body !== 'string') {
        return next(input, init);
    }
    const message = JSON.parse(init.body) as {
        method?: string;
        params?: { _meta?: Record<string, unknown>; [key: string]: unknown };
    };
    // Only requests and notifications carry params._meta; skip responses.
    if (message.method === undefined) {
        return next(input, init);
    }
    message.params = {
        ...message.params,
        _meta: {
            ...message.params?._meta,
            // Replace with values from your tracer's active span context.
            [TRACEPARENT_META_KEY]: '00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01'
        }
    };
    return next(input, { ...init, body: JSON.stringify(message) });
});

const transport = new StreamableHTTPClientTransport(new URL('http://localhost:3000/mcp'), {
    fetch: applyMiddlewares(traceContextMiddleware)(fetch)
});

On the server side, handlers can read the incoming trace context from ctx.mcpReq._meta — see the server guide.

Resumption tokens

When using SSE-based streaming, the server can assign event IDs. Pass onresumptiontoken to track them, and resumptionToken to resume from where you left off after a disconnection:

let lastToken: string | undefined;

const result = await client.request(
    {
        method: 'tools/call',
        params: { name: 'long-running-operation', arguments: {} }
    },
    {
        resumptionToken: lastToken,
        onresumptiontoken: (token: string) => {
            lastToken = token;
            // Persist token to survive restarts
        }
    }
);
console.log(result);

For an end-to-end example of server-initiated SSE disconnection and automatic client reconnection with event replay, see sse-polling/client.ts.

See also

  • examples/ — Full runnable client examples
  • Server guide — Building MCP servers with this SDK
  • MCP overview — Protocol-level concepts: participants, layers, primitives
  • Migration guide — Upgrading from previous SDK versions
  • FAQ — Frequently asked questions and troubleshooting

Additional examples

Feature Description Example
Parallel tool calls Run multiple tool calls concurrently via Promise.all parallel-calls/client.ts
SSE disconnect / reconnection Server-initiated SSE disconnect with automatic reconnection and event replay sse-polling/client.ts
Multiple clients Independent client lifecycles to the same server parallel-calls/client.ts
URL elicitation Handle sensitive data collection via browser elicitation/client.ts