What MCP observability starts from: the calls
Ship an MCP server and other people’s agents start using your product with no one from your team in the room. You get the calls. You get none of the conversation that caused them. That is enough to work with, once you know which parts of the protocol carry meaning.
MCP observability is the practice of reading those requests as sessions rather than as a log: which agents connect, which tools they pick, where a task stops, and which results sent an agent back for another try. This guide covers what the protocol exposes, how to group calls into sessions, the 4 signals a sequence of calls gives away, 2 optional fields worth adding to your server, and what stays out of reach.
Every MCP client goes through the same steps, and each step says something about the agent on the other end.
| Message | What it carries | What it tells you |
|---|---|---|
initialize | clientInfo with a name and version, plus capabilities | Which agent this is: Claude Code, Cursor, ChatGPT, or a product’s own agent, and which release |
Mcp-Session-Id | A header on every request over Streamable HTTP | Which calls belong together. This is the key that turns a log into sessions |
tools/list | Your tool names, descriptions and input schemas | What the model read before it chose. Every description here costs context on the agent’s side |
tools/call | A tool name and its arguments | What the agent decided to do, and how it understood your schema |
| The result | Content blocks, structured content, and isError | What you handed back. isError is yours to set, and most servers never set it for a result that is merely wrong |
Over stdio, where a local client spawns your server as a process, there is no session header. The process is the session: it starts at initialize and ends when the client exits. The grouping still holds. It is just implicit.
A successful call can be a failed task
Take the CRM used across this site. It ships an MCP server with 3 tools: find_contacts, update_contacts and log_activity. An agent working for a sales team calls them in that order to update the contacts from a trade-show list.
In 1 session, find_contacts returns 9 contacts. update_contacts takes all 9 in 1 call. It updates 3 and skips 6, because their emails don’t match any record, and the tool reports success because the code ran. log_activity then logs the 3 that were updated. 3 calls, 3 successes, and the person who asked for 9 contacts gets 3.
Now scale it. 2,400 sessions call this server to do the same task. The server log shows 2,400 sessions and a very low error rate. Read as tasks, 1,536 finished, which is 64%. 92 stopped at find_contacts, 528 stopped at update_contacts, and 244 stopped at log_activity. The update step loses 22.9% of the sessions that reach it, and every one of those sessions got a 200.
Nothing in the log says so. The agent does. It calls update_contacts again with the arguments changed. Or it calls log_activity for 3 contacts instead of 9. Or it stops. Those moves are visible in the sequence of calls, and only in the sequence. A call on its own is a success. A session is where the task shows.
Group the log by session
A server log arrives interleaved. 3 agents working at once produce 1 stream of calls, and every row says 200. Group the rows by session ID and the same stream reads as 3 tasks with 3 different endings. Try it.
- claude-code 2.1
find_contacts200 · 9 contacts - cursor 1.8
find_contacts200 · 9 contacts - claude-code 2.1
update_contacts200 · 9 updated - acme-agent 0.4
find_contacts200 · 9 contacts - cursor 1.8
update_contacts200 · 3 updated, 6 skipped - claude-code 2.1
log_activity200 · 9 logged - cursor 1.8
update_contacts200 · 3 updated, 6 skippedagain,match_bychanged - acme-agent 0.4
update_contacts200 · 3 updated, 6 skipped - acme-agent 0.4
log_activity200 · 3 logged3 of 9 contacts
claude-code 2.1find · update · logfinished
cursor 1.8find · update · updatestopped on update them
acme-agent 0.4find · update · log3 of 9 logged
Grouping needs 2 things from your server: keep the session ID with every call you log, and record the clientInfo from initialize against that session. Then every question about agents becomes a question about sessions. How many finished. Which client finishes most. Which tool the stopped ones stopped on.
What to record for every call
Most MCP servers log what their web framework logs: a path, a status and a duration. None of the signals in this guide can be read from that. The record below is small, it holds no customer values, and it is enough for everything that follows.
| Field | Where it comes from | Why it matters |
|---|---|---|
| Session | Mcp-Session-Id, or the process ID over stdio | Groups calls into a task. Without it there is only a log |
| Client and version | clientInfo at initialize | The cohort. A new client version changes which model reads your descriptions |
| Account | The OAuth token on Streamable HTTP, or the API key | Whose agent this is. The customer behind the session |
| Order | A counter per session | The sequence is where every signal lives |
| Tool | The tools/call name | Which step of the task this was |
| Argument shape | The keys present, their sizes, and any enum values. Never the values themselves | Shows the argument-change signal, and keeps customer content out of the log |
| Result shape | Block count, size, isError, and the keys of any structured content | A partial result, 3 contacts where 9 were asked for, shows here and nowhere else |
| Duration | Your clock | Slow tools get retried too, and a retry on a slow tool reads as a hang to the person |
| Tool list version | A hash of the tools/list response the session received | Lets you compare sessions before and after a description change |
| Intent and feedback | The optional fields, when an agent sends them | The agent’s own words, where you have them |
2 of these need a decision rather than a line of code. Argument values stay out of the log unless they already live in your product under its own rules, because an agent will put a customer’s contact names, emails, record IDs and sometimes their text straight into a tool call. And the tool list version is worth recording from day 1, because the first thing you will change after reading sessions is a description, and you will want to know whether it helped.
4 signals the sequence gives away
An agent cannot tell you it is unhappy. It can only act, and 4 of its actions are legible from the server side without any help from the client.
- Retry. The same tool, the same session, the same arguments, called again. The agent did not believe the first result. On the CRM’s server this is the strongest marker of the skipped-contacts failure, because the call reports success while the count falls short.
- Argument change. The same tool again with 1 argument altered: a different
match_by, a narrower filter, a smaller batch. The agent is guessing at what your schema wanted. Count which argument changes most and you have found the description that confuses models. - Fallback. A call to a second tool that overlaps with the first. 2 tools that both update contacts is a sign the names or descriptions overlap, and the agent picked by coin toss.
- Abandon. The session ends after a result that a finished task would have carried forward. An update with no activity logged after it is an abandon, whatever the status code said.
Each signal is a pattern over 2 or more calls, so none of them exists in a per-call log. They exist once the log is grouped. That is the whole argument for sessions.
Each one is a query over the record from the previous section, not a model judgement. The rules below are the ones to start with; tighten the windows once you have seen a month of sessions.
| Signal | Rule | What to count |
|---|---|---|
| Retry | Same session, same tool, same argument shape and the same values hash, within the next 3 calls | Sessions with at least 1 retry, per tool |
| Argument change | Same session, same tool, within the next 3 calls, with 1 key added, removed or changed | Which key changed, per tool. The top key is the description to fix |
| Fallback | Tool B called within 2 calls of tool A, where A and B are in the same group you defined (both export, both search) | Pairs of tools, ranked by how often B follows A |
| Abandon | The session’s last call is a step before the task’s final step, and no call follows for longer than the session’s longest gap so far | Sessions ending at each step. This is the stop-step number |
The values hash in the retry rule is the one place a value touches the log, and it is a hash, so the value itself never does. Without it, a call for 9 contacts and a call for 3 contacts look the same, and a retry is indistinguishable from the agent sensibly narrowing its request.
Which agent, which version, which account
A conversation has a person on the other end, and the useful questions are about who they are: which plan, which region, whether they have been here before. A session has an agent on the other end, and the same questions apply to it. 3 of them are answerable from the handshake.
- The client.
clientInfo.nametells you whether the session came from a coding agent, a chat product, or a harness your customer built. Expect a long tail of SDK default names, where a developer never set one. Treat those as 1 cohort, “custom agent, unnamed”, and expect it to behave differently from the named ones, because its model and prompt are unknown to you. - The version. A client’s new release often swaps the model underneath it, and a different model reads your descriptions differently. When finished sessions drop after a version appears, the drop has a cause you can name. This is period-over-period work: finished in this period against the last, split by version.
- The account. On Streamable HTTP, the OAuth token identifies the user and the organisation behind the agent. That is your customer. Group sessions by account and “which customers’ agents stop most” becomes a list, and it is the list customer success has been asking for.
Expect the spread between cohorts to be wide. On the conversation side of this site’s example, the same task finishes for 71% of self-serve users and 52% of enterprise users in EMEA, 19 points apart on 1 task. Client cohorts on a server spread at least as far, because the clients run different models with different prompts against the same descriptions. A single finish rate for the server is an average over agents that have little in common.
2 fields worth adding to your server
Braintrust shipped MCP server tracing this week and suggested 2 optional additions to a tool schema. Both are good ideas, and the point of adopting them is that a convention only helps if every server uses the same one.
telemetry.intent. An optional field on each tool’s input schema where the agent states, in a sentence, why it is calling. An update call might carry “update the 9 new leads from the trade-show list”. It is the closest an MCP server gets to the person’s words.send_feedback. A tool with no job except to be called when a result was confusing or unusable. The agent reports the tool, the arguments and what it did instead. On the CRM’s server, the report would read “6 of the 9 emails didn’t match, so I updated the other 3”.
The intent field, added to a tool’s input schema
{
"name": "update_contacts",
"inputSchema": {
"type": "object",
"properties": {
"contacts": { "type": "array", "items": { "type": "object" } },
"match_by": { "type": "string", "enum": ["email", "name_and_company"] },
"telemetry": {
"type": "object",
"properties": { "intent": { "type": "string", "description": "Why you are calling, in 1 sentence." } }
}
},
"required": ["contacts"]
}
}
The field is optional in the schema, so a client that ignores it still validates. Keep the description short and in the imperative: the model reads it as an instruction to itself. Log the sentence as the first thing in the session record, because once it is there, every number in this guide can be split by what the agent said it was trying to do.
A feedback call, as the server receives it
{
"name": "send_feedback",
"arguments": {
"feedback": "6 of the 9 contacts sent to update_contacts were skipped: no email match. Logged the other 3.",
"context": {
"tool": "update_contacts",
"arguments": { "match_by": "email", "contacts": ["dana.ortiz@…", "sam.lee@…", "priya.nair@…", "…"] }
}
}
}
Treat both as extra signals, not the signal. An agent fills them in only when its harness was told to, and most of the agents calling a public server were not. The 4 signals above come free with every session. Intent and feedback arrive from the agents whose developers cared enough to add them, which makes those sessions the most useful ones to read first.
Which tools get picked, and what the list costs
Every session starts with tools/list. The model reads every name, description and schema you return, then chooses. 2 numbers fall out of that, and most servers track neither.
- Listed against called. Of the sessions that listed a tool, how many called it. A tool that is listed in every session and called in none is a description problem, or a tool that duplicates another. Rename it or cut it.
- The cost of the list. Your descriptions ride along in the agent’s context on every model request that follows, in every session, for every client. Count the tokens in your
tools/listresponse with the tokeniser of the models your clients run, and treat that figure as a price you charge every user of every agent. Shorter descriptions that agents pick correctly are a feature.
The second number connects to the first. A long description that agents still misread costs twice: once in context, and again in the retries it causes.
Fix the result before the description
When sessions show a tool being retried, the first instinct is to rewrite its description. Look at the result first. The skipped-contacts failure on the CRM’s server is a result problem: update_contacts took 9 contacts, updated 3, and returned a success that said nothing about the 6 it skipped. The model cannot see which records changed. It took the success at face value, and the person found out at turn 9.
The fix is to put what the code knows into the result, in a shape the model can act on.
Before: 9 contacts, 6 of them skipped, and a success
{ "content": [{ "type": "text", "text": "Contacts updated." }],
"isError": false }
After: the partial result says it is partial
{ "content": [{ "type": "text", "text": "Updated 3 of 9 contacts. 6 had no email match; retry with match_by set to name_and_company." }],
"structuredContent": {
"updated": ["dana.ortiz@…", "sam.lee@…", "priya.nair@…"],
"skipped": [
{ "email": "j.park@…", "reason": "no contact with this email; retry with match_by set to name_and_company" },
{ "email": "m.alvarez@…", "reason": "no contact with this email; retry with match_by set to name_and_company" }
]
},
"isError": false }
3 things changed. The text block states the count, so a model that reads only text still knows it has 3 of 9. The structured content lists what was skipped and why, so the agent can act: set match_by and call again for the 6, or tell the person which contacts need a decision. And isError stays false, because a partial result is a result. Reserve isError for calls that produced nothing, or the agent learns to ignore it.
Now the description. A description is read once, at tools/list, before the model knows what it will need. A result is read at the moment of decision. Guidance belongs in the result, and the description only has to make the tool easy to pick and the arguments easy to fill.
| Before | After |
|---|---|
| Updates contacts. | Updates the given contacts, matching each to a record by match_by (email by default). Returns the contacts it updated and lists the rest in skipped with a reason. Contacts with no email match need match_by set to name_and_company. |
The after version names the argument the agent in the figure above was guessing at. It also says what the result looks like, which is the sentence most descriptions leave out. Change the description, record the new tool list version, and read the next period’s sessions. If the retry rate on update_contacts falls and the argument-change signal moves from match_by to nothing, the description worked. If it does not, the problem was never the description.
6 numbers for the dashboard
With sessions recorded and the signals defined, the dashboard for an MCP server has 6 numbers on it. Here they are for the CRM’s server, using the same 2,400 sessions.
- Sessions. 2,400. Every session that got past
initializeand made at least 1 call. - Finished. 1,536, which is 64%. Judged, as the next section explains, by the task’s last step completing with no retry after it.
- Stop step. Where the rest ended: 92 at
find_contacts, 528 atupdate_contacts, 244 atlog_activity. As a rate of the sessions that reached each step: 3.8%, 22.9% and 13.7%. The update step is both the biggest count and the worst rate. Halve it and 264 more sessions finish, and 64% becomes 75%. - Retry rate per tool. The share of sessions that called a tool twice with the same arguments. Track it per tool and per tool list version, so a description change has a before and an after.
- Listed against called. For every tool, the sessions that saw it and the sessions that used it. The tools nobody calls are the first candidates to cut, and cutting them shortens the list every agent pays for.
- Finish rate by client. The 64% split by client name and version, and by account. This is the number that turns a server-wide average into something a team can act on, because the fix for a client that reads descriptions badly is different from the fix for a tool that returns partial results.
Notice what is missing. There is no latency chart at the top and no request count. Both are worth having, and both are already in the web framework’s log. The 6 above are the ones only sessions can give you.
What the server cannot see
Sessions make the log readable. They leave 4 things out of reach, and a dashboard that pretends otherwise will mislead the team.
- The person. What they typed, what they meant, and whether they were satisfied. The nearest you get is
telemetry.intentfrom agents that send it. - What the agent did with the result. It may have retried the skipped contacts itself, or shown the 3 to the person as finished work. The log ends at your boundary.
- Sessions that never started. An agent that read your tool list in a registry and chose another server leaves no trace here. Compare registry views with sessions if the registry publishes them.
- Whose data the arguments hold. Tool arguments carry customer content: contact names, emails, record IDs, sometimes the text itself. Log the shape of the arguments and the signals, and keep the values under the same rules as the product’s own data.
Task success on an MCP server is therefore a judgement, not a measurement. A session that ran find, update and log once each and ended is probably a finished task. A session with a retry in it probably is not. Say “probably” in the report and the number stays trustworthy.
From stopped sessions to a test suite
Every stopped session is a test case that wrote itself. It has the arguments an agent sent, the result your server returned, and the signal that followed. Keep those 3 and you have a regression suite for the server that grows from production, which is the only place the odd cases come from.
The case for the skipped-contacts failure is the second update_contacts call from the cursor session in the figure above: the 9 contacts as arguments, the success that updated 3, and the retry with match_by changed. The pass rule is written from the signal: given these arguments, the result must list every contact it could not update. Run it against the next version of the server. It passes when skipped comes back with the 6 contacts and a reason each.
- Keep the client in the case. A case from cursor 1.8 may not reproduce under claude-code 2.1, because the 2 clients read the same description differently. Record which client produced the case, and run the suite per client where you can.
- Judge the result, not the status. The pass rule checks the shape of the result against what the task needed: 9 contacts updated, or fewer plus a reason for each. A rule that checks for a 200 passes the version that failed.
- Read the next period’s sessions. A passing suite says the old case is fixed. Only new sessions say whether agents stopped retrying. Check the retry rate on the tool for the period after the release, against the period before.
This is the loop the evals guide describes for conversations, with sessions where the transcripts would be: read production, find what to test, write the pass rule, run it on every release, and go back to production to see whether the number moved.
Reading sessions the way you read conversations
A session is a conversation whose turns are tool calls. It has a task, the thing the agent came to do. It has intents inside the task: find the contacts, update them, log the activity. It has signals at particular turns: the retry at the second update, the 3 contacts at log. And it has cohorts: the client, the client version, the account the agent works for.
That is the same model Abracadabra applies to the conversations an agent has with people, turned around to face the agents that talk to your server. The features page shows the task, intent, signal and cohort objects on a conversation; on a session they read the same way, with the client where the person would be. Connect the sessions your server logs, and the questions get answered the way this guide answered them: 2,400 sessions, 1,536 finished, 528 stopped on update, and the calls behind each number. It starts free: see pricing.
MCP observability FAQ
What is MCP observability?
MCP observability is reading the requests agents make to your MCP server as sessions, so you can see which agents connect, which tools they pick, where a task stops, and which tool results sent an agent back for another try.
What can an MCP server see about the agent calling it?
The client’s name and version from the initialize handshake, a session ID on Streamable HTTP, every tools/list and tools/call request with its arguments, and the result your server returned, including the isError flag. It never sees the person’s words or the agent’s reasoning.
Why can a successful MCP tool call still be a failed task?
Because a call reports whether your code ran, not whether the result was any use. An update tool that takes 9 contacts, updates 3 and skips 6 whose emails didn’t match has succeeded as a call and failed as a task. The agent shows you by calling again, changing an argument or giving up.
Should I add telemetry.intent and a send_feedback tool to my MCP server?
Yes, if the clients you care about will fill them in. They are optional fields that let an agent state why it is calling and report a result it could not use. Treat them as extra signals on top of the session, not as the only ones, because most agents will not send them.