MCP Observability: Audit Tool Calls Without Logging Secrets
One approved MCP tool call with well-meant telemetry left a bearer token, an unpublished draft, a signed preview URL and a user's email in the access log and trace store, and its failure path put a database password in the model's context. This guide classifies each field before it is emitted, allowlists the 2026-07-28 Mcp-Name and Mcp-Param headers at the edge, carries a server-minted approval ID through input_required rounds, and rebuilds the decision chain from the audit log alone. It ends with a canary suite that fails when any sink holds a secret, or holds nothing.

requestState, and treat client-supplied traceparent and baggage as join hints, not as identity. Then prove it: plant one canary per path (headers, arguments, results, error messages, baggage), drive every outcome, and fail the build if any sink holds a canary or if any sink is empty.An MCP server schedules social posts for a marketing agent. Its tool takes a post ID, a channel and the draft text, asks the user to approve, then calls the publishing API. The team adds an access log and OpenTelemetry spans so they can debug it. In the lab behind this article, one approved call with that well-meant telemetry left the agent's bearer token, the unpublished draft, a signed preview URL and the user's email address spread across two stores:
naive success records=11 leaks=[header -> access, arg -> access, baggage -> access, arg -> spans, result -> spans, baggage -> spans]
naive upstream error records=10 leaks=[header -> access, arg -> access, baggage -> access, arg -> spans, error -> spans, baggage -> spans]
naive declined records=11 leaks=[header -> access, arg -> access, baggage -> access, arg -> spans, baggage -> spans]
Each name on the left of an arrow is a planted fake secret on one path, and each name on the right is the sink it reached. The failing call did something worse than log a password: the database password in the downstream error ended up in the text the model reads. Every one of those records came from code that was trying to help with debugging. The log store now holds copies of credentials and private content, under the log store's access list and the log store's retention period, which are almost never the ones the original data had.
Part 1 of this series recorded one audit line per JSON-RPC request at the server edge. Part 4 designed write tools with previews and confirmation, and Part 5 checked egress for canaries. This part is about the telemetry itself: what to record for each tool call so an incident can be reconstructed, and the tests that show the records never become the next incident.
@modelcontextprotocol/server and @modelcontextprotocol/client 2.4.0, zod 4.6.7, @opentelemetry/api 1.10.0, @opentelemetry/sdk-trace 3.0.0 and @opentelemetry/core 3.0.0 (the 3.0.0 line shipped on 2026-10-20), on Node.js 26.12.1, macOS arm64. OpenTelemetry JS 3.0.0 removed @opentelemetry/sdk-trace-base, but npm still tags its 2.12.0 as latest, and that version's peer range stops short of API 1.10.0, so install @opentelemetry/sdk-trace instead. The OpenTelemetry MCP conventions are cited at commit f49da22 (2026-10-17) and have Development status. Spans went to an InMemorySpanExporter, the client reached the server in-process through handler.fetch, and every host is a reserved .test or example name. All output below is real, trimmed only for width.Five ways a secret gets into the record
The lab plants one obviously fake value on each path a tool call can carry, then searches every sink for it after each run. The five paths are not hypothetical; each produced a hit above.
| Canary | Where it rides | How the naive telemetry recorded it |
|---|---|---|
cnry_hdr_… | Bearer token in the Authorization header | Access log copied every request header |
cnry_arg_… | Draft text in the tool arguments | Access log copied the body; span set gen_ai.tool.call.arguments |
cnry_res_… | Signature in the preview URL the tool returns | Span set gen_ai.tool.call.result |
cnry_err_… | Password inside a downstream connection error | Span status and exception event, plus the tool result |
cnry_bag_… | User email the host put in W3C baggage | Access log body, and a span attribute copied from baggage |
Two of these follow the OpenTelemetry MCP conventions to the letter. gen_ai.tool.call.arguments and gen_ai.tool.call.result are defined there, as Opt-In attributes, each carrying a warning that it may contain sensitive information. Under OpenTelemetry's requirement levels, instrumentation SHOULD populate an Opt-In attribute only if the user configures it to. A team that enables them to debug one bad call and never turns them off has the outcome in the second row.
Give every field a class before it gets a column
The brief for an audit record is short: who acted, on what, under which approval, with what outcome, how fast. Everything else needs a reason. Sorting candidate fields into classes turns "is this safe to log" from a judgement made in code review into a lookup:
| Class | Fields in this tool | Rule |
|---|---|---|
| Identifier | actorId from the verified token, postId, approvalId, traceId, tool name | Record. Take identity only from what the server verified |
| Decision and state | Approval decision, status, error class, postVersion | Record, from a closed set of values |
| Measurement | Latency, HTTP status, JSON-RPC code | Record |
| Fingerprint | copyHash of the draft text | Record only as a keyed hash whose key the log store never sees |
| Never | Tokens, draft text, prompts, tool results, error messages, baggage values | Drop before the record is built |
The fingerprint row is there because a plain SHA-256 of the draft looks safe and is not always. OpenTelemetry's guidance on sensitive data warns that hashes are reversible in practice when the input space is small and predictable. A post that is one of three approved templates, or a short reply such as "Confirmed", can be recovered by hashing the candidates. An HMAC with a key held outside the logging system still lets you answer the audit question, whether the approved text was the text published, without letting a log reader test guesses.
The OWASP Logging Cheat Sheet gives the outer list: access tokens, session identifiers, passwords, connection strings and sensitive personal data should be removed, masked, hashed or encrypted rather than recorded directly. The MCP logging page is stricter for its own channel. Log messages MUST NOT contain credentials, personal identifying information or internal system details that could aid attacks. Its own example notification still carries a database host and port, so do not copy it as a template.
Keep a named list at the HTTP edge, and expect headers you did not design
The first leak in the table came from the most ordinary line of code in the server:
// Wrong: the access log is a copy of the request
sinks.access.push({ method: request.method, url: request.url,
headers: Object.fromEntries(request.headers), body: await request.clone().text(), status: response.status });
The fix is a list of what the log keeps, not a list of what it removes. A denylist of authorization and cookie misses every header someone adds next quarter:
const ACCESS_ALLOW = ['mcp-method', 'mcp-name', 'mcp-protocol-version', 'content-length'];
sinks.access.push({ method: request.method, path: new URL(request.url).pathname, status: response.status, ms: Date.now() - t0,
...Object.fromEntries(ACCESS_ALLOW.filter((h) => headers[h]).map((h) => [h, headers[h]])),
...Object.fromEntries(Object.keys(headers).filter((h) => h.startsWith('mcp-param-')).map((h) => [h, '[present]'])) });
The 2026-07-28 revision is the reason for the last line. Its Streamable HTTP transport adds required Mcp-Method and Mcp-Name headers, and Mcp-Name carries params.uri for resources/read, so a resource path now appears in every access log and proxy that records headers. The OpenTelemetry conventions also make mcp.resource.uri Conditionally Required on client spans, with file:///home/user/documents/report.pdf as their example; URIs that contain user names or document titles belong in the fingerprint class. Tool parameters can be mirrored too: a property marked x-mcp-header in the input schema is copied by conforming clients into an Mcp-Param-{Name} header, so that load balancers and WAFs can route on it. The tools page says server developers SHOULD NOT mark passwords, keys, tokens or PII that way, because intermediaries can see headers. Nothing stops a schema change from doing it anyway, which is why the edge records only that a mirrored header was present. The lab server exposes tools only, so its allowlist keeps mcp-name, which there is always a tool name. A server that also serves resources should keep mcp-name only when mcp-method is tools/call or prompts/get, and record a keyed fingerprint of it for resources/read.
Mirrored values are also not always readable as plain text. A value with non-ASCII characters or surrounding spaces is sent as a Base64 sentinel. With a canary in the channel argument, SDK 2.4.0 sent:
Mcp-Param-Channel: =?base64?w6lxdWlwZSBjbnJ5X3BybV9LMmRWOG5BNA==?=
A scanner that searches log text for cnry_prm_K2dV8nA4 reports that header as clean. The canary suite below decodes sentinels before it compares.
The hardened run's access log for one approved call, with nothing in it worth stealing:
{"method":"POST","path":"/mcp","status":200,"ms":1,"mcp-method":"server/discover","mcp-protocol-version":"2026-07-28"}
{"method":"POST","path":"/mcp","status":400,"ms":0,"mcp-method":"tools/call","mcp-name":"schedule_post","mcp-protocol-version":"2026-07-28"}
{"method":"POST","path":"/mcp","status":200,"ms":1,"mcp-method":"tools/list","mcp-protocol-version":"2026-07-28"}
{"method":"POST","path":"/mcp","status":200,"ms":1,"mcp-method":"tools/call","mcp-name":"schedule_post","mcp-protocol-version":"2026-07-28","mcp-param-channel":"[present]"}
{"method":"POST","path":"/mcp","status":200,"ms":0,"mcp-method":"tools/call","mcp-name":"schedule_post","mcp-protocol-version":"2026-07-28","mcp-param-channel":"[present]"}
Note the 400. The lab's client called the tool before it had listed tools, so it did not know to send Mcp-Param-Channel. The server refused the request, the client listed tools and retried, as the transport page says it SHOULD. In the naive run that refused request was logged in full, draft text included, although no handler ever ran. An edge log records refusals, which Part 1 argued for; one that copies bodies also records the arguments of every request the server refused.
Return an error class and a reference, not the message
The upstream error path did the most damage, because the fake publishing client threw an error whose message contained a connection string. The SDK's documented behaviour is that anything a tool handler throws becomes an isError result with the exception's message as its text. A naive handler that records the exception and rethrows produces this:
result [{"type":"text","text":"connect failed: postgres://svc_social:[email protected]:5432/drafts"}] isError=true
span status {"code":2,"message":"connect failed: postgres://svc_social:[email protected]:5432/drafts"}
span event [ 'exception.type', 'exception.message', 'exception.stacktrace' ]
The password is now in the model's context, in the trace backend, and in whatever transcript store the host keeps. The handler should catch, classify, and return something a model can act on without seeing internals:
} catch (err) {
const errorId = `err_${randomUUID().slice(0, 8)}`;
const errorClass = /connect failed/.test(err.message) ? 'upstream_unavailable' : 'internal';
span.setAttributes({ 'error.type': 'tool_error', 'social.error_class': errorClass });
span.setStatus({ code: SpanStatusCode.ERROR, message: errorClass });
audit({ event: 'tool.result', approvalId: state?.approvalId, status: 'error', errorClass, errorId, latencyMs: Date.now() - started });
span.end();
return { content: [{ type: 'text', text: `Scheduling failed (${errorClass}). Reference ${errorId}.` }], isError: true };
}
result [{"type":"text","text":"Scheduling failed (upstream_unavailable). Reference err_800b757a."}] isError=true
span {"error.type":"tool_error","social.error_class":"upstream_unavailable", ...} {"code":2,"message":"upstream_unavailable"}
error.type is tool_error because the MCP conventions say so for a result with isError set; the finer class goes in its own attribute. Classifying by matching the message is the weak point of this example. A real client library usually exposes an error code or type, which is the better thing to switch on.
Protocol errors raise the same problem one layer down. The conventions say that when a span's status is ERROR, its description SHOULD match the JSON-RPC error message. On the server side only five codes are exempt from being errors: -32700, -32600, -32601, -32602 and -32002. The 400 from the previous section was a -32020, so a server span for it is an error, and SDK 2.4.0's message for it repeats the argument value:
{"jsonrpc":"2.0","error":{"code":-32020,"message":"Bad Request: the request headers and body disagree: the body carries channel=\"équipe cnry_prm_K2dV8nA4\" but the Mcp-Param-Channel header is absent", ...},"id":0}
An instrumentation that follows the convention copies that string into the span. For your own spans, set the description to the class. For spans you do not write, the canary suite is how you find out.
Join on trace context, but do not trust it
The 2026-07-28 revision reserves three unprefixed _meta keys, traceparent, tracestate and baggage, for OpenTelemetry context, and requires W3C formats when they are present. The OpenTelemetry MCP conventions say server instrumentation SHOULD use the extracted context as the parent of its span. In the lab, one callTool() on the client produced three tools/call POSTs under one client span, all carrying the same traceparent:
200 server/discover id=server-discover-probe-1
400 tools/call id=0 00-2333e386fbeed9ad672e4ad2010eeb2b-186908bd021bed00-01
200 tools/list id=1
200 tools/call id=2 00-2333e386fbeed9ad672e4ad2010eeb2b-186908bd021bed00-01
200 tools/call id=3 00-2333e386fbeed9ad672e4ad2010eeb2b-186908bd021bed00-01 requestState=v1.eyJwIjp7ImFwcHJvdmFsSWQiOi...
Three JSON-RPC IDs, one trace. That makes the trace ID the natural key for joining an access log, spans and audit events. It does not make it an identity, for two reasons in the specifications themselves. W3C Trace Context warns that a service which naively continues incoming traces lets an attacker forge trace-id collisions that make monitoring data unusable, so two unrelated calls can share one. And OpenTelemetry's baggage documentation says there are no built-in integrity checks on baggage entries. The convention's own example puts userId=alice in baggage; a server that records the actor from there records whatever the client claimed. In the lab, actorId comes from the verified token and approvalId is minted by the server. Neither is a value the client chose.
Baggage is also a leak path in the other direction. OpenTelemetry notes that automatic instrumentation adds baggage to most outgoing requests, so an email address placed there by a host travels to every downstream API your server calls. The W3C specification says traceparent and tracestate must never carry personal or sensitive information. Treat any baggage your server forwards as data you chose to send.
One more trap: the MCP specification links its trace-context rules to an OpenTelemetry page for MCP conventions. On 2026-10-27 that page is a stub saying the GenAI conventions have moved to the semantic-conventions-genai repository. That repository has no tags or releases, so pin a commit when you cite it.
Carry the approval ID through both rounds of the call
The third pitfall in a social-draft workflow is a log that says a post was approved and a log that says it was published, with nothing to prove they are the same post and the same text. Under 2026-07-28 the gap is wider than it looks. A tool that asks for confirmation returns an input_required result, and the client calls the tool again with the user's answer. The handler runs twice, in two requests, and the SDK says nothing on the server survives between rounds except requestState. So the approval ID has to travel in it:
const state = ctx.mcpReq.requestState();
if (!state?.approvalId) {
const approvalId = `apr_${randomUUID().slice(0, 8)}`;
audit({ event: 'approval.requested', approvalId, postVersion: drafts.get(postId)?.version, copyHash: copyHash(body) });
return inputRequired({
inputRequests: { approve: inputRequired.elicit({ message: `Publish post ${postId} to ${channel}?`, requestedSchema: confirmSchema }) },
requestState: await codec.mint({ approvalId, copyHash: copyHash(body) }),
});
}
const view = inputResponse(ctx.mcpReq.inputResponses, 'approve');
const ok = acceptedContent(ctx.mcpReq.inputResponses, 'approve', confirmSchema)?.approve === true;
const decision = ok ? 'approved' : view.kind === 'elicit' ? view.action : 'missing';
const sameCopy = state.copyHash === copyHash(body);
audit({ event: 'approval.decided', approvalId: state.approvalId, decision, copyMatches: sameCopy });
codec comes from the SDK's createRequestStateCodec, passed as the server's requestState.verify. The multi round-trip page says servers MUST treat requestState as attacker-controlled and MUST protect its integrity if it affects business logic, which this one does. The SDK's codec is HMAC-signed, and its documentation warns that it is signed, not encrypted. Decoding the token from the run above shows exactly what any client, proxy or naive body log can read:
{"p":{"approvalId":"apr_4729921a","copyHash":"hmac256:fae557592cd9c3b7"},"exp":1793078822}
That is why the draft text is not in it. The copyHash in the state is what lets the second round refuse a call whose text changed after the user approved. The lab binds only the text. Bind postId, channel and the verified actor into the same signed state, and refuse a second round whose values differ, so an approval for one post cannot be replayed onto another. The lab's client rewrites the draft on the retry, and the server refuses it:
{"event":"approval.requested","postId":"post_1044","approvalId":"apr_855f54bb","postVersion":2,"copyHash":"hmac256:fae557592cd9c3b7", ...}
{"event":"approval.decided","postId":"post_1044","approvalId":"apr_855f54bb","decision":"approved","copyMatches":false, ...}
{"event":"tool.result","postId":"post_1044","approvalId":"apr_855f54bb","status":"refused","errorClass":"copy_changed_after_approval", ...}
The elicitation page adds a constraint that helps here: servers MUST NOT use form mode to request sensitive information such as secrets and credentials. A yes-or-no approval has nothing secret in it, so its decision belongs in the audit record as a closed value.
Rebuild the decision chain from the audit log alone
The test for an audit format is whether someone who has only the log can answer the incident question. Three calls ran: one approved, one declined, one tampered. This query groups by the server-minted approval ID and checks that each chain stayed in one trace:
function decisionChain(audit, postId) {
const rows = audit.filter((e) => e.postId === postId);
return Object.entries(Object.groupBy(rows, (e) => e.approvalId ?? 'none')).map(([approvalId, ev]) => {
const asked = ev.find((e) => e.event === 'approval.requested');
const decided = ev.find((e) => e.event === 'approval.decided');
const result = ev.find((e) => e.event === 'tool.result');
return [postId, approvalId, asked?.actorId, `v${asked?.postVersion}`, asked?.copyHash,
decided?.decision ?? 'no decision', decided?.copyMatches === false ? 'copy changed' : 'same copy',
result ? `${result.status}${result.errorClass ? ':' + result.errorClass : ''}` : 'no result',
new Set(ev.map((e) => e.traceId)).size === 1 ? 'one trace' : 'SPLIT TRACE'].join(' | ');
});
}
post_1042 | apr_4729921a | social-agent | v2 | hmac256:fae557592cd9c3b7 | approved | same copy | ok | one trace
post_1043 | apr_6df51d81 | social-agent | v2 | hmac256:fae557592cd9c3b7 | decline | same copy | refused:not_approved | one trace
post_1044 | apr_855f54bb | social-agent | v2 | hmac256:fae557592cd9c3b7 | approved | copy changed | refused:copy_changed_after_approval | one trace
Who acted, which version, which text by fingerprint, what the user decided, and what happened, with no draft text and no token anywhere in the store. A chain with no decision next to ok is a write that skipped approval, and that query belongs in an alert, not only in a postmortem. The MCP security best practices ask for the same shape at the authorization layer: log scope elevation events, the scopes requested and the subset granted, with correlation IDs.
Fail the build when any sink holds a canary, or holds nothing
The suite plants the five canaries, drives each outcome (success, upstream error, declined), serializes every sink, and searches decoded views of the text. Part 5 decoded base64 and URL encoding for egress; log sinks add the MCP header sentinel:
const SENTINEL = /=\?base64\?([A-Za-z0-9+/=]+)\?=/g;
function views(text) {
const out = [text];
try { out.push(decodeURIComponent(text)); } catch {}
for (const [, b64] of text.matchAll(SENTINEL)) out.push(Buffer.from(b64, 'base64').toString('utf8'));
return out;
}
for (const mode of ['naive', 'hardened']) {
for (const [name, args, opts] of PATHS) {
test(`${mode}: ${name}`, async () => {
resetSinks();
const client = await connect(mode, opts);
const res = await tracedCall(client, { postId: 'post_1042', channel: 'linkedin', ...args });
const hits = findCanaries();
assert.ok(sinks.access.length > 0 && sinks.audit.length > 0 && spans().length > 0, 'sinks are empty: the test would pass vacuously');
if (mode === 'naive') assert.ok(hits.length > 0, 'the scanner found nothing in a configuration known to leak');
if (mode === 'hardened') {
assert.deepEqual(hits, []);
assert.ok(!JSON.stringify(res.content).includes(CANARIES.error), 'error detail reached the model');
}
});
}
}
hardened success records=11 leaks=[]
✔ hardened: success (2.913583ms)
hardened upstream error records=11 leaks=[]
✔ hardened: upstream error (2.276375ms)
hardened declined records=11 leaks=[]
✔ hardened: declined (2.199959ms)
ℹ pass 6
ℹ fail 0
The two assertions before the real check carry more weight than they appear to. A leak test passes trivially when a sink receives nothing, and that happened in this lab. OpenTelemetry JS 3.0.0 changed the SimpleSpanProcessor constructor from new SimpleSpanProcessor(exporter) to new SimpleSpanProcessor({ exporter }), as its migration guide records. The old call still constructs without an error and then exports nothing. With it in place, the hardened runs reported records=8 leaks=[], three fewer records and no complaint, and only the emptiness guard failed:
✖ hardened: success (10.376458ms)
AssertionError [ERR_ASSERTION]: sinks are empty: the test would pass vacuously
The naive mode is the other half of that control. It is a configuration known to leak, kept in the suite on purpose, so a scanner that stops matching fails a test instead of quietly approving everything.
Debug traces and audit records need different owners
The two stores in this article answer different questions and should not share a policy. Spans and access logs are for engineers debugging latency and failures: high volume, broad read access, retention in days. The audit stream is for "who approved this post": low volume, narrow read access, retention set by whatever obligation made you keep it. OWASP's rule cuts both ways: log data must not be destroyed before the required retention period ends, and must not be kept beyond it. Putting both in one index means either the debug data is kept for a year or the audit trail expires in a week.
OWASP also allows sanitizing after collection, before display. For anything in the "never" class, prefer collection time. A dashboard filter or collector processor that masks a token at query time leaves the original in storage, in backups and in every export. The OpenTelemetry Collector's attribute, redaction and transform processors are worth running as a second layer, but OpenTelemetry's own guidance says the best way to avoid collecting sensitive data is not to collect it.
The MCP logging utility is not the place for any of this. The 2026-07-28 revision deprecates it, with removal possible in the first revision released on or after 2027-07-28, and points to stderr for stdio servers and OpenTelemetry for structured observability. Its notifications/message goes to the client, which MAY display or persist it, so anything a server logs that way leaves the server's control.
A model can usefully summarise sanitized telemetry, such as clustering error classes after a release. Give that summary the access controls of the audit store, not of a chat channel. A summary built only from post IDs, actors and timestamps can still reveal who was drafting what, and when.
What this costs, and where it stops
You lose the payload when you need it. When a published post renders wrong, the trace will not show the text. Reproduce from the source of truth instead, the drafts store at postId and postVersion, which already has the right access controls. If engineers need raw exception detail, write it under the errorId to a separate store with its own short retention and access list, and add that store to the canary suite.
The HMAC key is a secret with a lifecycle. Rotate it and old fingerprints stop matching new ones, so record a key version beside the hash.
The canary suite only covers seeded paths and the sinks it can read. Auto-instrumentation in a proxy, an APM agent that captures request bodies, or a crash reporter are sinks too, and each needs to be in sinkTexts() or behind an argument that it receives nothing. The final part of this series, on Playwright MCP versus a regression test suite, deals with a different question: what an agent-driven browser can and cannot replace.
Plant the first five canaries this week
- List every sink a tool call can reach: access logs, spans, the audit stream, error reporting, and the host's transcript store. Write the five-class table for one tool's fields and get it agreed before changing code.
- Replace any header or body copy in your HTTP logging with a named allowlist. Record
Mcp-Param-*headers as present, and decide whetherMcp-Namecan carry a resource URI with personal data in it. - Wrap each write tool's handler so thrown errors become a class plus an error ID. Check that no exception message reaches the result, the span status or an exception event.
- For any tool with a confirmation, mint an approval ID into signed
requestState, record the decision, and run the decision-chain query over a day of audit records. Everyokshould have anapprovedbefore it. - Add the canary test with both guards, the empty-sink check and a known-leaky configuration. Keep the test output and the query output as the evidence.
Questions about MCP telemetry and audit
Is it ever right to turn on gen_ai.tool.call.arguments?
For a tool whose arguments are fixed identifiers and enums, in a development environment, briefly. The conventions mark it Opt-In with a sensitivity warning for that reason. If you enable it in production, the canary suite has to run against that configuration, and the trace store inherits the argument data's access rules.
Does a stdio server need any of this?
Less of the HTTP edge: there are no headers or access logs, and clients on transports other than Streamable HTTP may ignore x-mcp-header. The rest still applies. The stdio transport lets the client capture or forward whatever the server writes to stderr, so stderr is a sink you do not control, and a thrown error still becomes the tool result text.
Should the audit record use mcp.session.id?
The conventions recommend it when a request is part of a session, but the 2026-07-28 revision has no protocol-level sessions, so modern requests have none to record. On legacy connections, treat a session ID like any bearer handle: OWASP suggests a hashed value if you need to group events by session.
Primary references
- Versioning page of the MCP specification. Names 2026-07-28 as the revision in force on 2026-10-27.
- Base protocol overview, MCP 2026-07-28. The reserved unprefixed
traceparent,tracestateandbaggagekeys in_meta, W3C formats required, and the link to the OpenTelemetry MCP conventions page. - MCP logging utility in revision 2026-07-28. Deprecation in favour of stderr and OpenTelemetry, the MUST NOT on credentials, PII and internal details in log messages, and the example notification with a host and port.
- Deprecated features in MCP 2026-07-28. Logging deprecated by SEP-2577, earliest removal in the first revision on or after 2027-07-28.
- Tool definitions and
x-mcp-header, MCP 2026-07-28. Header mirroring of tool parameters, the SHOULD NOT on marking sensitive parameters, and the client SHOULD to log tool usage for audit. - Streamable HTTP transport, 2026-07-28 revision. Required
Mcp-MethodandMcp-Nameheaders,Mcp-Namecarryingparams.uri, the Base64 sentinel encoding,-32020on a mismatch, and the client SHOULD to re-list tools and retry. - Multi round-trip requests in MCP 2026-07-28.
requestStateas attacker-controlled input with integrity protection required when it affects business logic. - Elicitation page, MCP 2026-07-28. Form mode MUST NOT be used to request secrets or credentials.
- stdio transport, MCP 2026-07-28. Servers may write logs to stderr, and clients may capture, forward or ignore it.
- Security best practices for MCP implementers. Tokens obtained through log leakage, and logging scope elevation events with correlation IDs.
- OpenTelemetry semantic conventions for MCP at commit f49da22. Context propagation through
params._meta, Opt-In argument and result attributes with sensitivity warnings,error.typeastool_error, status description matching the JSON-RPC message, the five codes not treated as errors, andmcp.resource.urirequirement levels. - Attribute requirement levels in OpenTelemetry semantic conventions. Opt-In attributes populated only when the user configures the instrumentation to do so.
- Former OpenTelemetry site page for MCP conventions. The page the MCP specification links to, now a notice that the GenAI conventions moved to their own repository.
- Handling sensitive data in OpenTelemetry. Data minimization, the Collector processors for removing and hashing attributes, and the warning that hashes of small input spaces are reversible.
- Baggage concept page on opentelemetry.io. Baggage sent on most outgoing requests by automatic instrumentation, and no built-in integrity checks.
- W3C Trace Context recommendation. No personal or sensitive data in
traceparentortracestate, and forged trace-id collisions as a denial-of-monitoring risk. - OWASP Logging Cheat Sheet. Risk framing: data to exclude, mask or hash, interaction identifiers, sanitization after collection, protection of stored logs, and retention in both directions.
- Errors guide for the TypeScript SDK v2. A thrown handler exception becoming an
isErrorresult with the message as its text. - input_required guide for the TypeScript SDK v2. Handler re-entry,
requestStateas the only cross-round memory, andcreateRequestStateCodecbeing signed, not encrypted. - TypeScript SDK v2.4.0 release. The SDK version the lab ran, including
Mcp-Param-*mirroring and the-32020message. - OpenTelemetry JS v3.0.0 release notes. Removal of
sdk-trace-basein favour of@opentelemetry/sdk-trace, released 2026-10-20. - OpenTelemetry JS 3.x migration guide at v3.0.0. The
SimpleSpanProcessorconstructor change to an options object.