Table of contents
If you want to know who calls your MCP server, which tool they ask for and what failed, log one JSON line per HTTP request and read the outcome from the response body, not only the status code. In our test, three kinds of failure (a tool that threw, an unknown tool name and bad arguments) all came back as HTTP 200, so a log of status codes alone showed them as successes. The log should hold the JSON-RPC method, the tool name, the names of the arguments (never the values), the status, the outcome and the duration.
This guide builds that log on a small Streamable HTTP server and shows the lines it wrote on 3 November 2026. It also uses our own endpoint's numbers to show what a real log looks like.
Quick summary
Direct answer: Put a logger in front of the MCP handler, record the JSON-RPC method and params.name from the request body, and sniff the response body for isError and error.code. Add the client name from the initialize request, hash the caller's credential, and never write argument values.
| What to log | Where it comes from | Why |
|---|---|---|
| HTTP status | Response | Finds 401, 405 and 400 |
JSON-RPC method | Request body | Tells tools/list from tools/call |
| Tool name | params.name of a tools/call | Shows which tool is used |
| Argument names | Keys of params.arguments | Debugging without user data |
| Outcome | isError or error.code in the response | Tool failures arrive as HTTP 200 |
| Client name | clientInfo.name in initialize | The only place a client names itself |
| Duration | Timer around the request | Finds slow tools |
Best for: anyone who has shipped an MCP server over HTTP and cannot say what happens on it.
Scan your site first: npx aiscan-cli yoursite.com, or paste the address at aiscan.site. It is free and needs no account. The scan covers check P2 (MCP server card) in the capabilities group. It cannot see your request logs, which is why this article exists.
What the protocol gives you, and what it does not
The MCP logging utility is not request logging. It lets a server send notifications/message to the client, with a syslog level from RFC 5424 such as info or error. A server that sends them must declare the logging capability. Those messages travel to the client, so they help the person debugging in the client and tell you nothing about who called your server.
Request logging is your job. The transport rules require Streamable HTTP servers to validate the Origin header and to authenticate connections, and say nothing about what you record. Everything below is a convention, not a requirement.
Two facts shape the design. The client introduces itself once, in initialize, through clientInfo.name. A stateless server never sees that name again. And the JSON-RPC method and tool name live in the body, so an access log that records only the URL shows every call as POST /mcp.
Path 1: log in middleware (tested)
We ran this on 3 November 2026 with Node 24.22.1, Express 5.3.0 and @modelcontextprotocol/sdk 1.32.2, with a stateless Streamable HTTP server and two tools: add, which works, and fail, which throws.
- Install the packages:
npm i @modelcontextprotocol/sdk express zod. - Add the logger before the MCP handler and before your auth check, so 401 responses are logged too:
app.use('/mcp', (req, res, next) => {
const start = process.hrtime.bigint();
const msg = Array.isArray(req.body) ? req.body[0] : req.body;
const method = msg?.method ?? null;
const tool = method === 'tools/call' ? msg?.params?.name : null;
const argKeys = tool ? Object.keys(msg?.params?.arguments ?? {}).sort() : null;
const client = method === 'initialize' ? msg?.params?.clientInfo?.name : null;
const auth = req.get('authorization');
const caller = auth ? sha256(auth).slice(0, 8) : null;
- Capture the response body. The SDK writes bytes, so convert each chunk with
Buffer.from(c).toString()before you search it:
let sniffed = '';
const write = res.write.bind(res), end = res.end.bind(res);
res.write = (c, ...a) => { sniffed += Buffer.from(c).toString(); return write(c, ...a); };
res.end = (c, ...a) => { if (c && typeof c !== 'function') sniffed += Buffer.from(c).toString(); return end(c, ...a); };
- On
finish, work out the outcome and append one line. A match on"isError":trueis a tool error, and"error":{"code":on a 2xx response is a JSON-RPC error:
res.on('finish', () => {
let outcome = res.statusCode >= 400 ? 'http_error' : 'ok';
const m = sniffed.match(/"isError":true/) ? 'tool_error' : sniffed.match(/"error":\{"code":(-?\d+)/);
if (m === 'tool_error') outcome = 'tool_error';
else if (m && res.statusCode < 400) outcome = 'rpc_error:' + m[1];
// append JSON.stringify({ http, status, outcome, rpc: method, tool, arg_keys: argKeys, client, caller, ms }) to a file
});
next();
});
- Put
express.json()before this logger, as it must be, and add an error handler that logs unparseable bodies. In our first run a request with a body of{not jsonproduced no log line at all, because the parser rejected it before the logger ran.
We then sent twelve requests with a script. These are the lines it wrote:
| HTTP | Status | Outcome | JSON-RPC method | Tool | Argument names | Client |
|---|---|---|---|---|---|---|
| POST | 200 | ok | initialize | claude-ai | ||
| POST | 202 | ok | notifications/initialized | |||
| POST | 200 | ok | tools/list | |||
| POST | 200 | ok | tools/call | add | a, b | |
| POST | 200 | tool_error | tools/call | fail | ||
| POST | 200 | tool_error | tools/call | nope | secret | |
| POST | 200 | tool_error | tools/call | add | a | |
| POST | 401 | http_error | tools/list | |||
| POST | 401 | http_error | tools/list | |||
| POST | 400 | http_error | ||||
| GET | 405 | http_error | ||||
| POST | 200 | rpc_error:-32601 | resources/list |
Rows 5 to 7 are the reason to read the body: a thrown error, an unknown tool and a missing argument each returned HTTP 200. Row 12 is an unknown method, answered 200 with JSON-RPC error -32601. A status-only log marks all four as healthy. Each request took 0.6 to 26 milliseconds on localhost, and the slowest was the first one, initialize.
What the sketch does not do: it logs the first message of a batch only, it keeps no session, and the client name appears on the initialize line only. To join later lines to a client, key a map by the session ID or by the hashed credential.
Path 2: log at the edge (not run here)
If you cannot change the server, log at the reverse proxy. This path was not tested for this article. The nginx variables $status, $request_time and $http_user_agent exist, and you can add them to a log_format.
- Define a JSON
log_formatwith$time_iso8601,$status,$request_time,$request_method,$request_uriand$http_user_agent. - Point
access_logfor the/mcplocation at it. - Alert on the share of 401, 429 and 5xx lines per hour.
The limit is real: nginx records POST /mcp for every call, so you cannot tell tools apart. $request_body exists only when the body was read into a memory buffer in a proxied location, and logging it writes every argument to disk, which Path 1 avoids on purpose.
What a real log looks like
Our own server answers at /mcp (OAuth sign-in) and /api/mcp. Our 67-day usage study read the crawler log on 1 November 2026:
| Measure, 26 Aug to 1 Nov 2026 | Count |
|---|---|
| Requests to the two paths from a named AI user agent | 388 |
| Of those, from Claude-User | 387 |
Claude-User requests to /mcp since 11 Oct | 53 |
| Of those, answered 401 | 52 |
| Scans completed through the MCP surface | 375 of 3,723 |
| Errors on the MCP surface | 0 |
The 401 is the first step of an OAuth sign-in, so it is normal. If you alert on 401 volume, split first-contact 401s from repeated ones. The OAuth discovery guide shows what the client reads next, and the server card guide covers discovery. The usage study's one limit applies here too: only some clients send a user agent.
Privacy rule for arguments
Tool arguments carry whatever the model decided to send, including a user's text. Log the names, as above. If you log values, say so in your privacy notice, cap their length and redact known fields. The sha256 of the Authorization header, cut to 8 characters, lets you count callers without storing a credential. It is a correlation tag, not anonymity.
If you would rather use the SDK's own logging
In @modelcontextprotocol/sdk 1.32.2, sendLoggingMessage exists on the server and the SDK throws if you call it without declaring the logging capability. We did not test the Python SDK. Use these notifications for client-side visibility and keep your request log separate.
Next step
Run npx aiscan-cli yoursite.com and read P2 under capabilities, then add the logger and send the same twelve requests to confirm your log shows the failures. More how-tos are in the guides and the developer guide.
Frequently asked questions
How do I log requests to an MCP server?
Add a logger in front of the MCP handler that records the HTTP status, the JSON-RPC method, the tool name from params.name, the names of the arguments, the outcome and the duration, and writes one JSON line per request.
Why does my log show 200 when a tool call failed?
MCP reports tool failures inside the result with isError set to true, and unknown methods as a JSON-RPC error, both on an HTTP 200 response. In our 3 November 2026 test a thrown error, an unknown tool and a missing argument all returned 200. Read the response body to see them.
Why is there no log line for my malformed request?
If express.json() runs before your logger, a body that is not valid JSON is rejected before the logger sees it. In our test a body of {not json produced no line until we added an error handler that logs it.
Why do I see a 401 on the first request from every client?
With OAuth, the first unauthenticated request is answered 401 so the client can find the sign-in details. On our server 52 of 53 Claude-User requests to /mcp since 11 October 2026 were 401s, which is normal, not an outage.
Does the MCP logging utility record who called my server?
No. It lets a server send notifications/message log messages to the client, with syslog levels. It does not record requests on the server. A server that sends them must declare the logging capability.
How can I tell which client is calling?
The client names itself once, in clientInfo.name of the initialize request. A stateless server sees it only on that request, so key a map by the session ID or a hashed credential to attach the name to later lines.
Should I log tool arguments?
Log the argument names, not the values. Arguments can hold user text. If you do log values, cap their length, redact known fields and say so in your privacy notice.
Why does my nginx access log show only POST /mcp?
The JSON-RPC method and tool name are in the request body, not the URL, so nginx records every call the same way. Log in middleware to see them, or log $request_body knowing it writes every argument to disk.

About the author
M Asif Rahman
Entrepreneur, curious about AI
I have been building companies for a long time, and lately most of my curiosity goes to AI. AIScan is a small personal project I built on a flight from Dubai to Narita. I use it to understand how ready the web is for AI agents, and I share what the scans teach me.
Related guides
MCP Server Cards on 808 Sites: 32 Pass, and 52 of 65 Partials Fail E1
Of 808 hosts in our scan corpus, 32 pass check P2, the MCP server card check. Another 65 answer HTTP 200 at the card path and earn only partial credit, and 52 of those 65 also fail our soft404 check.…
Scan a site from Claude with the AIScan MCP connector
You can run an AIScan audit without leaving a chat. Add https://aiscan.site/mcp as a remote MCP connector in Claude or in any client that accepts a remote MCP URL, sign in with your AIScan account…
We Run a Public MCP Server: 67 Days of Who Calls It, and How
We run a public MCP server, and for 67 days we have been counting who uses it. Between 26 August and 1 November 2026, AI assistants asked it for scans 375 times, which is 10.1% of the 3,723 scans…
