Chapter 5 of 7 · 18 min read
Capability mismatch and schema rejection
On this page
The initialize exchange is a mutual declaration, and everything either side is permitted to do
for the rest of the session is bounded by it. The client says what it can receive and what it can
offer, the server answers with what it supports, the client sends notifications/initialized, and
the session begins. A capability in this sense is a named feature — resource change
notifications, per-resource subscriptions, structured logging, sampling — that one side announces
and the other may then rely on. A capability that was never announced is one the other side may
legitimately drop on the floor.
That produces a failure with no artefact: the feature is implemented, the code path runs, the message is written in the correct shape, and one side is entitled to ignore it. A second artefact-free failure sits next to it, where a client validates the model's arguments against your published schema and declines to send the request at all. Both are findable in minutes once you know which absence to look for.
A trace where every frame conforms
Here is the frame log from a session in which a client is failing to notice that a resource list has changed. It covers two cache refreshes, two minutes apart:
14:22:01.007 s->c ntf notifications/resources/list_changed 62B
14:22:01.009 ———— (no reaction)
14:22:44.310 c->s req id=88 tools/call get_invoice 198B
14:22:44.352 s->c res id=88 1204B
14:24:01.006 s->c ntf notifications/resources/list_changed 62B
14:24:01.008 ———— (no reaction)The notification is well-formed, it is sent on schedule, it uses the correct method name, and it goes out on a connection that is demonstrably alive because a tool call succeeds on it in between. Every frame in that log conforms. Nobody who scans it looking for something wrong will find anything, and the natural conclusion — the server is doing its part, so the report is a client bug or a user error — is available immediately and is the wrong one about half the time.
That conclusion is tempting because it follows a rule which is usually sound: when a system misbehaves, look at what it did. A frame log is an excellent record of what a system did, and this one is complete — two notifications, a request, a response, with nothing dropped and nothing elided. What a log cannot record is what a system did not do, and it fails to record it in the most unhelpful way available: a message that was never sent leaves no gap, no placeholder and no error, so there is nothing in the log for a careful reader to be careful about. The log is therefore not weak evidence about the missing feature. It is silent about it, and silence read as an answer is how a correct report gets closed as invalid. Locating this fault needs a different instrument altogether — not a record of the messages that crossed, but the pair of frames in which the two sides settled which messages either of them was entitled to rely on.
The information is in the handshake:
{
"jsonrpc": "2.0", "id": 0, "method": "initialize",
"params": {
"protocolVersion": "2025-06-18",
"capabilities": { "roots": { "listChanged": true }, "sampling": {} },
"clientInfo": { "name": "client-b", "version": "3.2.0" }
}
}{
"jsonrpc": "2.0", "id": 0,
"result": {
"protocolVersion": "2025-06-18",
"capabilities": {
"tools": { "listChanged": true },
"resources": {}
},
"serverInfo": { "name": "invoices", "version": "1.9.3" }
}
}The server declares resources with an empty object. It did not declare listChanged, and it did
not declare subscribe. A client reading that response learns that this server exposes resources
which can be listed and read, and that it will never announce changes to them — so a client that
responds by not registering a handler for notifications/resources/list_changed, and silently
dropping any that arrive, is behaving exactly as specified.
Why a declaration should bind anybody is worth deriving rather than accepting, because the objection
to it is a reasonable one: the server does emit the notification, the bytes do arrive, so what
entitles the client to throw them away? The answer is a property of the situation rather than a rule
somebody preferred. Two programs meet here that were written by different people, built at different
times and shipped in different releases. Neither was compiled against the other, neither can read
the other's source, and no third party holds a manifest of what either one contains. Nothing either
side believes about the other can therefore come from inspection, which leaves exactly one source of
material to reason from: what the other said during initialize. One rule survives that constraint
— a feature may be relied on when it was declared, and not otherwise — and the consequence follows
with no further assumption. For the length of a session, an undeclared capability is an absent one,
whatever the code on the far side is in fact able to do.
That rule reads more easily in one direction than the other, and the easy direction is the one that misleads. Adding the declaration is necessary before any client will register a handler, and an engineer who has just supplied a missing declaration will reasonably expect that to be the whole of the fix. It is not sufficient, and the space between the two is where the next ticket comes from. The client must also have implemented the feature, must have wired its handler to something that genuinely re-fetches, and must be running a protocol revision in which the notification exists at all. A declaration buys permission to rely on a feature. Permission to rely is not delivery, and a server that has issued the permission has completed one half of a two-sided arrangement.
How the declaration goes missing is worth knowing, because it is rarely a typo. Most SDKs derive
the capability object from what you registered: pass a listChanged: true flag or register a
change-emitting handler and the field appears, do neither and it does not, even though calling
server.sendResourceListChanged() elsewhere in your code compiles and runs perfectly well. The two
halves of the feature live in different files and nothing checks that they agree.
// Declares the capability. Without the flag, resources: {} goes on the wire
// and every list_changed notification you send is legitimately ignored.
const server = new Server(
{ name: "invoices", version: "1.9.3" },
{ capabilities: { tools: { listChanged: true },
resources: { listChanged: true, subscribe: true } } },
);Behind that split is a mechanism worth stating plainly, because it explains why the defect survives code review. The declaration is computed once, at construction, out of the registry the constructor was handed. The emission happens much later, in whatever code path refreshes the cache, through a session object that has no knowledge of what the constructor was told. Nothing joins the two: the type of the notify call does not depend on the capability object, the SDK has no reason to refuse a message it was never asked to advertise, and a reviewer reading the refresh path sees a correct call to a real method. Each file is right on its own terms. The defect exists only in the relationship between them, which is the one thing a review of either file cannot see.
The same asymmetry runs in the other direction and bites harder. A server that calls
sampling/createMessage — the request by which a server asks the client to run a prompt through
its own model on the server's behalf — against a client that never declared sampling, or
roots/list, which asks the client which directories or URLs the server is permitted to work in,
against one that never declared roots, or elicitation/create, which asks the client to put a
question to the human user and return the answer, against one that declared neither, is issuing a
request the client is entitled to reject with a method-not-found error — or, in some
implementations, to ignore. If the feature is optional in your product, the correct shape is to
read the client's declared capabilities out of the initialize params, store them on the session,
and branch on them, rather than to call and handle the failure.
Both directions are instances of one structure, and setting the structure out shows why the two failures are opposites rather than variations on a theme. A capability-gated message does anything only when two independent conditions hold: the side that sends it has implemented it, and the side that receives it has been told to expect it. Those conditions live in different files, and across a vendor boundary in different companies, so they combine four ways. Declared and implemented is the working case. Neither declared nor implemented is a feature nobody has, which is not a defect at all. The other two are the failures, and each hides its evidence in a place its owner has no reason to look.
Implementing without declaring is the case in the trace above, and its signature is success on the
sending side and nothing whatever on the receiving side. Your log records a notification written and
sent, the frame log shows it leaving well formed, and the receiver records nothing, because a
message for an undeclared capability is discarded before any layer treats it as worth noting.
Declaring without implementing is the mirror, and where its evidence lands depends on how the far
side exercises the capability. If the capability is exercised by a request — a client calling
resources/subscribe because you advertised subscribe — the far side takes you at your word,
sends it, and gets a method-not-found error back. That is an error in their log and an unrecognised
method in yours, which makes it the one case of the four that both parties can see. If the
capability is instead something the far side waits on, such as a listChanged notification you
declared and never emit, then nobody sends anything and nobody errors, so the failure appears in
neither log. One constraint produces both results: a log records messages, so a failure whose
content is a message that was not sent has to be found somewhere other than a log.
Resource change notification is only the most visible member of this family. A server that
registers new tools at runtime and expects clients to re-fetch depends on tools.listChanged;
without it, a client is entitled to call tools/list exactly once at startup and use that answer
for the life of the session, which presents as a tool that "isn't there" for everyone who connected
before the deploy. A server that supports per-resource subscriptions needs resources.subscribe
declared or its notifications/resources/updated messages go nowhere. A server that emits
structured log messages through notifications/message needs logging, and a client that never
declared interest will not have wired up a place to put them. Every one of them fails at the same
joint, which is why a single guard catches all of them: the sender implements the feature, the code
path runs, the message goes out correctly formed, and the receiver is contractually permitted to
drop it on the floor — permitted by the handshake the sender itself took part in.
Checking a negative means diffing declarations against behaviour
There is a mechanical version of this investigation that takes about ten minutes and finds every
instance at once. Capture one initialize request and response from a real connection — an
inspector shows both, and a frame log contains them as the first two lines of any session. Then
write down two lists: every capability-gated message your server ever sends or expects to receive,
and every capability either side declared. Anything in the first list without a matching entry in
the second is a feature that will silently not happen.
jq -r 'select(.method=="initialize") | .params.capabilities' frames.jsonl
jq -r 'select(.id==0 and .result) | .result.capabilities' frames.jsonl
grep -ohE 'notifications/[a-z/_]+|sampling/[a-zA-Z]+|roots/[a-z]+' src/ -r \
| sort -uThe output of the third command is your behaviour; the first two are your contract. In the case
above the grep listed notifications/resources/list_changed and
notifications/resources/updated, and the declaration listed neither.
Doing it by hand once is fine; the version that keeps working is a guard in the server. Two lines at the point of emission turn a silent no-op into a log entry that names itself:
function emitResourceListChanged(session: Session) {
if (!session.serverCapabilities.resources?.listChanged) {
log.warn("emitting list_changed for an undeclared capability", {
client: session.clientInfo.name, sessionId: session.id,
});
}
session.notify("notifications/resources/list_changed");
}The same guard on the receiving side — checking session.clientCapabilities.sampling before
issuing sampling/createMessage — converts a request that will be rejected into a branch you
chose. A unit test that constructs the server, enumerates the capability-gated methods it can emit,
and asserts each one is declared costs twenty lines and catches the whole family before a client
ever sees it.
Protocol version belongs in the same check. The client proposes a version, the server answers with the version it will actually use, and if the server names one the client does not support the client is expected to close the connection rather than proceed. A subtler version of the same problem is a session that negotiates down to an older revision, in which a feature you rely on does not exist — everything works, nothing errors, and one capability is quietly absent for that client only.
An invalid argument for a call that never arrived
The second artefact-free failure has a different shape and the same central lesson. A user reports
that the assistant said their date filter was invalid; the server's log contains no
search_invoices call at all in the fifteen minutes around it, at any log level, and the frame log
has no request either.
The sequence is that the client validated the model's arguments against the tool's own
inputSchema and declined to send the request. This is normal client behaviour and in most SDKs it
is not optional. The schema you publish in tools/list is a contract the client enforces on your
behalf before anything crosses the wire, so a rejection against it produces an error message in the
client's log and nothing anywhere else:
14:41:09 ERROR tool call validation failed: search_invoices
instancePath: /since
keyword: format
message: must match format "date"
data: "last month"That error is the whole diagnosis, and it is on a machine you may not own. The failing schema path
— /since — names the field, and the keyword names the rule that rejected it. Nothing in your
handler is involved, and no change to your handler will affect it.
Before that error is in hand there is a tempting move available, and it is worth naming because it closes tickets that should stay open. Your own empty log looks like the finding: nothing reached the server, therefore nothing was called, therefore whatever went wrong went wrong before anybody tried to use the server. A step is missing from that. An empty log establishes exactly one thing, which is that no request reached the process that writes the log. The reasons for that include a request nobody ever composed, a request the client refused to send, a request that died in a proxy, and a request that arrived at a different instance behind a load balancer. Only the second is a defect in the schema you published, and separating it from the other three costs one line of somebody else's log rather than any further work on yours. An absence is a branch point, and reading it as a conclusion picks a branch without looking down the others.
The fix lives in one of two places. Either the schema is right and the description is not doing its
job, in which case the tool's description and the field's description need to state the expected
format and give an example, because that text is the only instruction the model gets. Or the schema
is stricter than the tool needs, in which case widening it — accepting an ISO date or a relative
expression, and normalising server-side — removes the rejection at the cost of taking on the
parsing yourself. Tightening a loose since to format: date is what produces this fork: it fixes
a silent empty result and converts it into a client-side rejection, which is an improvement in that
the failure is now loud, and is not yet a fix.
Which side of the wire a bad argument dies on is therefore not an accident of the client. It is a
property you choose when you write the schema, and the two choices fail in opposite places. A loose
schema — since typed as a bare string — passes the client's validator by construction, since a
validator can only enforce what the schema states. The request crosses the wire, arrives at your
handler, and is recorded by your log, so a bad value becomes a wrong answer you can at least watch
being produced. A strict schema moves the same rejection forward onto the client, which now has a
rule to test the value against and a reason to stop. That failure is louder and earlier, and it
happens on a machine whose log you may have no way to read. Looseness buys visibility and pays for
it in silent wrong answers; strictness buys a real rejection and pays for it by moving the evidence
out of reach. Neither is the safe default, and a server that publishes a strict schema owes the
model a description precise enough that the rejection almost never fires.
It is worth separating this from the two failures it resembles. A server-side validation failure is
a JSON-RPC error with code -32602, the invalid-params code, and it appears in your log because
the request reached you. A tool that ran and failed for a business reason is a normal result with
isError: true and a message the model can read, and it also appears in your log. Only the
client-side rejection leaves your side of the wire completely empty, and that emptiness is the
signature.
| Call never returns | Invalid argument, no call logged | Feature never happens | |
|---|---|---|---|
| Name | transport hang | client-side schema rejection | capability mismatch |
| Decisive evidence | connection and timeout state | the client's validation error and schema path | the initialize declarations |
| Where it lives | process, socket, proxy, stack | the client's log | the first two frames of the session |
| What a trace shows | the last frame in each direction — useful | no frame at all — an absence | a fully conforming trace — misleading |
| Usually whose defect | yours or your infrastructure's | yours, in the schema or the description | yours, in the declaration; sometimes neither |
| Wrong first move | add a global timeout | edit the handler | conclude the report is invalid |
The row that decides how a morning goes is the fourth. Tracing is the right instrument for the first column, gives you an informative absence in the second, and actively misleads in the third, because a clean trace reads as evidence of correctness to anyone who has not asked what should have been in it.
A mismatch can legitimately be nobody's defect
On the question of whose fault each of the three is, the honest answers differ. A hang is yours until the connection evidence says otherwise, and a proxy closing an idle connection under a long-running tool is the common exception. A schema rejection is yours, though not in the code you would have gone looking at — the client is doing its job correctly and the fault is in the contract you published.
A capability mismatch is the one that can end with no defect anywhere. If the client simply does
not implement resource change notifications, then your server declaring listChanged and the
client ignoring it is two conforming implementations with different feature sets, and the correct
response is to fall back to polling or to document the limitation rather than to file a bug. That
outcome is worth reaching deliberately, because the same evidence — a conforming trace and an
unhappy user — is what the "report is invalid" conclusion is built from, and the two differ only in
whether anybody read the handshake.
Triage order follows from the cost of the evidence rather than from the severity of the ticket. The capability question is answered by reading two frames you already have, so it takes minutes and either eliminates a ticket or solves it. The schema rejection needs one line from the reporter's client log, which is a message to send and then wait on, so it starts early and finishes when they reply. The hang is the expensive one, because its evidence is perishable — the connection state, the process, the stack — and none of it survives the restart that somebody will perform to unblock the user. When all three land at once, the order that wastes least is to ask for the client log, read the initialize frames while waiting, and spend the remaining time on the hang with instrumentation in place before it recurs rather than forensics after.
What unites the two failures in this chapter is that the decisive evidence is an absence, and an absence is only visible to somebody who knows what should have been there. A frame log answers "what happened"; these two need "what was supposed to happen and did not", which is a question about the handshake and about the schema rather than about any individual message. Reading two frames from the start of a session is the cheapest diagnostic in this topic, and it is the one most often skipped, because the frames are forty minutes older than the symptom and nothing about them looks like an error.
That reasoning is comfortable only while both sides agree on what the specification says. It stops working when your server behaves correctly with two clients, fails with a third, and that vendor's support engineer replies that your server is the non-conforming one.
Debugging MCP — read the current version at https://daviddominguez.dev/learn/en/debugging-mcp/capability-mismatch-and-schema-rejection