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.
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
<- traceThe 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 200Always 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:createMiddlewarebuilds one,applyMiddlewarescomposes many, and the transport'sfetchoption takes the result. - The last middleware passed to
applyMiddlewaresis 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.
withLoggingandwithOAuthship in@modelcontextprotocol/client.- A middleware sees both directions: the request before
next, theResponseafter it.