---
title: "Log the Requests to Your MCP Server: Who Called, Which Tool, What Failed"
slug: log-mcp-server-requests
published: 2026-11-03T08:10:54.473381+00:00
updated: 2026-11-03T08:10:54.473381+00:00
author: "M Asif Rahman"
author_url: https://ar.bd
category: "AI Readiness"
tags: check:P2, mcp, logging, observability, streamable-http, how-to
description: "Log an MCP server's requests: method, tool, outcome and duration. Tested on a local Streamable HTTP server, with 12 sample lines and the privacy rule."
url: https://aiscan.site/blog/log-mcp-server-requests
---

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](https://aiscan.site/). It is free and needs no account. The scan covers check P2 (MCP server card) in the [capabilities group](/docs/checks/capabilities). 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](https://modelcontextprotocol.io/specification/2025-11-25/server/utilities/logging) 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](https://modelcontextprotocol.io/specification/2025-06-18/basic/transports) 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.

1. Install the packages: `npm i @modelcontextprotocol/sdk express zod`.
2. Add the logger before the MCP handler and before your auth check, so 401 responses are logged too:

```js
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;
```

3. Capture the response body. The SDK writes bytes, so convert each chunk with `Buffer.from(c).toString()` before you search it:

```js
  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); };
```

4. On `finish`, work out the outcome and append one line. A match on `"isError":true` is a tool error, and `"error":{"code":` on a 2xx response is a JSON-RPC error:

```js
  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();
});
```

5. 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 json` produced 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](https://nginx.org/en/docs/http/ngx_http_core_module.html) `$status`, `$request_time` and `$http_user_agent` exist, and you can add them to a `log_format`.

1. Define a JSON `log_format` with `$time_iso8601`, `$status`, `$request_time`, `$request_method`, `$request_uri` and `$http_user_agent`.
2. Point `access_log` for the `/mcp` location at it.
3. 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](/blog/public-mcp-server-usage-60-days) 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](/blog/oauth-discovery-for-agents) shows what the client reads next, and the [server card guide](/blog/publish-mcp-server-card) 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](/guides) and the [developer guide](/developers).

