How to monitor and log an MCP server
An MCP server is driven by a language model, so when something goes wrong you usually cannot reproduce it. The model picked the arguments, looped on a confusing result, or called a tool you did not expect. Good logs are how you find out what actually happened after the fact. The hard part is logging enough to debug an incident without writing secrets, prompts, or user data into a file that later leaks. This guide covers what to record, what to leave out, how to structure it, and the few alerts worth setting up first.
What to log on every tool call
Treat each tool call as one event and log a single line for it when it completes. The goal is to answer "who called what, did it work, and how long did it take" without reading the code. Capture these fields:
- Timestamp in UTC and ISO 8601, so logs from different machines sort correctly.
- Client identity if you have authentication: the API key ID or OAuth subject, never the raw key. Otherwise the session ID.
- Tool name and the MCP method (
tools/call,resources/read, and so on). - Outcome: success or error, plus a stable error code on failure.
- Latency in milliseconds, measured around the handler, including downstream calls.
- A request ID that ties the MCP call to any logs your downstream services emit.
Argument shape is useful, argument content usually is not. Log the argument keys and value types, or a count, rather than the values themselves. That tells you the model called send_email with a to and body field without putting the email body in your logs.
What never goes in a log
Logs get shipped to third-party platforms, copied into tickets, and read by people who are not on the project. Assume anything you write will be seen by someone who should not see the underlying data. Keep these out:
- Secrets: API keys, tokens, passwords, connection strings,
Authorizationheaders. See secrets management for where these should live instead. - Full tool arguments and results. These often contain file contents, query results, customer records, or the model's prompt. Log a length or a hash if you need to correlate, not the body.
- Personal data: emails, names, addresses, anything that turns a log store into a data-protection liability.
- Raw model output. It can echo back any of the above.
The reliable way to enforce this is a redaction step at the boundary, not careful discipline at every log call. Maintain a deny-list of sensitive keys and strip them before anything is serialized.
const SENSITIVE = new Set([
"password", "token", "secret", "apikey", "api_key",
"authorization", "cookie", "set-cookie", "connectionstring",
]);
function redact(obj) {
if (Array.isArray(obj)) return obj.map(redact);
if (obj && typeof obj === "object") {
const out = {};
for (const [k, v] of Object.entries(obj)) {
out[k] = SENSITIVE.has(k.toLowerCase()) ? "[redacted]" : redact(v);
}
return out;
}
if (typeof obj === "string" && obj.length > 200) {
return `[string len=${obj.length}]`; // never log long blobs verbatim
}
return obj;
}
Make the logs structured
Write JSON, one object per line (JSON Lines). A human can still read it, and any log platform can index it without a fragile regex. Plain-text logs like tool send_email failed in 240ms are unparseable at scale; the same event as a JSON object is queryable by field. Here is a small wrapper that logs one structured event per call and times it:
function logEvent(fields) {
process.stdout.write(JSON.stringify({
ts: new Date().toISOString(),
level: fields.error ? "error" : "info",
...fields,
}) + "\n");
}
async function withLogging(req, ctx, run) {
const start = performance.now();
const base = {
request_id: ctx.requestId,
client: ctx.clientId ?? ctx.sessionId ?? "anon",
tool: req.tool,
method: "tools/call",
arg_keys: Object.keys(req.args ?? {}), // shape, not values
};
try {
const result = await run();
logEvent({ ...base, ok: true, ms: Math.round(performance.now() - start) });
return result;
} catch (err) {
logEvent({
...base, ok: false, ms: Math.round(performance.now() - start),
error: err.code ?? "internal_error", message: err.message,
});
throw err;
}
}
Log to stdout and let the platform handle the rest. Do not manage log files inside the process. In containers, stdout is collected by the runtime; on a host, your service manager (systemd, for example) captures it. This keeps the server simple and means rotation and shipping are someone else's job.
Track latency and error rate, not just counts
A single number like "1,200 calls today" hides every problem worth knowing about. Three signals catch most incidents:
- Error rate per tool. A tool that jumps from 1% to 30% errors is usually a broken downstream dependency or a bad deploy. Per-tool matters: one failing tool can hide in a healthy global average.
- Latency percentiles. Track p50, p95, and p99, not the mean. The mean stays flat while p99 quietly climbs into timeouts. Agents retry on slow calls, so latency turns into extra load.
- Call volume per client. A sudden spike from one client is often an agent stuck in a loop. This is the same signal that feeds rate limiting; logging it lets you see the loop even when you have not capped it yet.
If you already emit the structured event above, every one of these is a query over tool, ok, ms, and client. No extra instrumentation needed.
Basic alerting worth setting up first
You do not need a full observability stack on day one. Start with a handful of alerts that map to "a human should look now," and tune the thresholds to your traffic so they do not cry wolf:
- Error rate over a threshold (for example, above 10% for any tool over five minutes).
- p99 latency past a ceiling that means clients are timing out.
- Any spike in
internal_error, which signals an unhandled exception rather than a clean tool failure. - A single client exceeding an expected call rate, the early sign of a runaway loop.
- Zero successful calls over a window during expected active hours, which catches a server that is up but quietly broken.
Route these to wherever you already get paged. The point is not coverage, it is catching the obvious failures before a user reports them.
Check what you are leaking before you ship
Redaction is easy to get subtly wrong: a nested field, a header logged by a library, an error object that serializes its full request context. Before shipping, scan for the patterns that put sensitive data on the wire in the first place. The free mcp-audit tool flags remote MCP servers running without authentication or over cleartext, the cases where unredacted logs and verbose errors do the most damage. It runs locally with no dependencies, so it is a quick check before a release. Pair it with a manual read of a few real log lines from staging: that is the fastest way to spot a secret or a payload that slipped through.
Structured logging without rolling your own
MCP Forge Kit ships a redacting JSON logger with per-tool timing, request IDs, and a secret deny-list wired into a hardened server template, so you log enough to debug without leaking.
Get MCP Forge Kit, €39Related: Secrets management · Rate limiting · Security checklist