TTypeScript SDKSource ↗
Compose client middleware

Compose client middleware

A middleware wraps the fetch a client transport uses, so it sees every HTTP request on the way out and every Response on the way back.

Write a middleware

createMiddleware builds one from a function that receives the next handler plus the request. Compose it onto fetch with applyMiddlewares and hand the result to the transport's fetch option.

import { applyMiddlewares, createMiddleware, StreamableHTTPClientTransport } from '@modelcontextprotocol/client';

const tagRequests = createMiddleware(async (next, input, init) => {
    const headers = new Headers(init?.headers);
    headers.set('X-Request-Source', 'reports-cli');
    return next(input, { ...init, headers });
});

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

Every request this transport sends now carries the header — including the requests the SDK sends that you never wrote, like initialize.

::: info Not the framework middleware packages This page is about client request middleware: functions that wrap the fetch inside @modelcontextprotocol/client. The @modelcontextprotocol/express, @modelcontextprotocol/hono, and @modelcontextprotocol/node packages also carry the word "middleware" — those are server-side framework adapters for mounting a handler. See Express and Hono. :::

Compose several middlewares

applyMiddlewares takes any number of middlewares; each one in the list wraps everything before it. Stub out the network and stamp each layer's name on both sides of next to watch the order.

const stamp = (name: string) =>
    createMiddleware(async (next, input, init) => {
        console.log(`-> ${name}`);
        const response = await next(input, init);
        console.log(`<- ${name}`);
        return response;
    });

const base = async () => new Response('ok');
await applyMiddlewares(stamp('retry'), stamp('auth'), stamp('trace'))(base)('http://localhost:3000/mcp');

The last middleware you pass is outermost — it sees the request first and the response last:

-> trace
-> auth
-> retry
<- retry
<- auth
<- trace

The first middleware you pass sits closest to the network. Put a retry there so every layer above it sees one settled Response.

Use the built-in logging middleware

withLogging ships in @modelcontextprotocol/client; called with no options it logs every request the wrapped fetch makes.

const loggedFetch = applyMiddlewares(tagRequests, withLogging())(fetch);

Connect through loggedFetch and call one tool. Four requests reach the wire, and you wrote one of them:

HTTP POST http://localhost:3000/mcp 200  (0ms)
HTTP POST http://localhost:3000/mcp 202  (0ms)
HTTP GET http://localhost:3000/mcp 405  (0ms)
HTTP POST http://localhost:3000/mcp 200  (0ms)

The POSTs are initialize, the notifications/initialized notification, and your tools/call; the GET opens the server-to-client stream, which this server declines. Pass statusLevel: 400 to log only failures, includeRequestHeaders / includeResponseHeaders to add headers to each line, and logger to replace the formatter entirely.

::: warning The default logger writes to console.log and console.error. In a process whose stdout carries an MCP stdio transport, pass your own logger so these lines stay off that stream. :::

Combine middleware with an auth provider

withOAuth(provider, serverUrl) is the OAuth flow expressed as one middleware layer: it adds the Authorization header, and on a 401 it re-authenticates against serverUrl and retries the request once.

const serverUrl = new URL('http://localhost:3000/mcp');
const authed = new StreamableHTTPClientTransport(serverUrl, {
    fetch: applyMiddlewares(withOAuth(provider, serverUrl), withLogging({ statusLevel: 400 }))(fetch)
});

provider is the same OAuthClientProvider you would hand to the transport directly. With statusLevel: 400, withLogging stays silent until a request fails.

::: tip For the common case, pass authProvider to the transport instead — see OAuth. withOAuth is for stacks that already own fetch and need auth composed with other layers. :::

Inspect the response

A middleware runs on both sides of next: read the request body before the call and the Response after it. Map each JSON-RPC method to the HTTP status it came back with.

const observeStatus = createMiddleware(async (next, input, init) => {
    const response = await next(input, init);
    if (typeof init?.body === 'string') {
        const { method } = JSON.parse(init.body) as { method?: string };
        console.log(`${method ?? 'response'} -> HTTP ${response.status}`);
    }
    return response;
});

Connecting through observeStatus and calling one tool prints one line per request that carried a body:

initialize -> HTTP 200
notifications/initialized -> HTTP 202
tools/call -> HTTP 200

Always return the Response; the transport consumes its body after you. To read the body too, read a response.clone().

Recap

  • A middleware wraps the transport's fetch: createMiddleware builds one, applyMiddlewares composes many, and the transport's fetch option takes the result.
  • The last middleware passed to applyMiddlewares is outermost; the first sits closest to the network.
  • A middleware sees every HTTP request the transport sends, including the ones the SDK sends on its own.
  • withLogging and withOAuth ship in @modelcontextprotocol/client.
  • A middleware sees both directions: the request before next, the Response after it.

Source: docs/clients/middleware.md

Generated by smolify with deterministic-repository-import-v1 · 1 source file
Compose client middleware — TypeScript SDK · Smolify