Post
Deploying your MCP server does not update its tool descriptions
We fixed one line in a tool description, deployed, and nothing changed. Reading the spec explained why. Shipping the server and updating the contract the client holds are two different things, and for `instructions` there is no update path defined at all. Here is the incident it caused, and why we moved our contracts out of prose and into the response path.
We fixed one line in a tool description. It was the rule that makes the model ask which currency a number is in, and the sentence was soft enough that the model kept skipping it. We tightened the wording and deployed. The deploy succeeded, the logs were clean, and the new code was clearly running.
Nothing changed.
Same account, same question, and the model still behaved by the old rule. We suspected caching, then the CDN, then checked three times that the build had actually shipped. It had. The deploy was fine. It was just that some things do not deploy.
What ships and what does not
An MCP server hands three kinds of things to a client.
- Tool descriptions (the
descriptionandinputSchemaintools/list) - Server instructions (the
instructionsfield in theinitializeresponse) - Tool results (the response to
tools/call)
Only the third one changes the instant you deploy. Every call is answered by whatever code is running right now.
The first two do not. They are handed over once, at connection time, and the client holds them from then on. If someone connected your connector three weeks ago, the model in that session is reading three-week-old tool descriptions while calling code you shipped this morning.
The spec explains it
We went and read the spec, and the two fields turn out to have very different fates.
The tool list has an update path. If a server declares
listChanged: true under its tools capability, it can notify clients when the list changes.{
"jsonrpc": "2.0",
"method": "notifications/tools/list_changed"
}
The client then re-requests
tools/list. Note the wording though: servers that declared the capability SHOULD send it. Not MUST. Sending it is a recommendation, and re-fetching on receipt is a recommendation.Server instructions have no update path.
instructions rides along in the initialize response, once. Nowhere in the lifecycle spec is there a notification saying instructions have changed. It is not optional, it is simply not defined. Which means reconnecting is the only way to update it.Anyone who has built an MCP server will see why this stings. Almost everything that governs model behavior lives in those two places. "Only use this tool when X." "Read this number this way." "Never infer a cause from a headline." Those are not logic, they are contracts, and the contract is frozen inside the client.
On serverless, you cannot even send the notification
You might think you could just send
tools/list_changed. On a serverless deployment, that is mostly not available either.To push a notification you need an open stream to that session. But deploying replaces your instances wholesale. The instance that held the old session is gone, and the new one has no idea who was connected. Even with a session backbone that knows the list, there is no guarantee the client has a stream open at that moment.
| How it updates | On deploy | |
|---|---|---|
| Tool results | Every call | Immediate |
| Tool descriptions | list_changed notification (SHOULD) | Effectively never |
| Server instructions | None | Unchanged until reconnect |
What actually happened
Everything so far is an inconvenience. Here is where it turns dangerous.
Opula has rules for classifying ledger entries: whether something is an asset or a liability, which category it belongs to. Those definitions live as sentences in a tool description. We revised them once and deployed.
Then a session that had connected before the deploy was still alive. The model in it held the old definitions, while the user's data had already been reorganized under the new ones. The model looked at that data, concluded the classification was wrong, and moved to revert it to match the definitions it was holding.
This was not a bug. Every part behaved correctly. The model faithfully honored the contract it had been given, and the server would have written exactly what it was asked to write. The two sides were simply holding contracts from different points in time.
This class of mismatch is quiet. Nothing throws. No type breaks. The status code is 200. Data just rolls backwards without a sound.
The lesson: a contract written in prose does not deploy
One rule came out of this.
If a contract must hold, do not write it in a description. Enforce it in the response path.
Opula has a rule that a recently purchased position must be recorded with the reason it was bought. At first it was written as a sentence in three separate places: the tool description, the server instructions, and a separate note. Three places, and it still was not honored. Worse, no matter how we rewrote the sentence, the revision never reached anyone already connected.
So we deleted the sentences and replaced them with a rejection. Recording a recent trade without a reason simply fails, and the error message says why it is needed.
That solved two things at once. The contract holds, and the contract now sits on the side that deploys. Rejection logic is code, and code takes effect the moment you ship. Someone who connected three weeks ago gets today's rule immediately.
In one line: a deploy changes your code, not your contract. So the contracts you need to change belong in code.
If you run an MCP server
A few things worth carrying over.
1. Treat tool descriptions and server instructions as write-once, long-lived artifacts. Put a rule that changes often in there and you will keep producing states where you changed it and nothing changed.
2. Rules that must not be broken belong in the response path. Validation, rejection, forced defaults. Descriptions are guidance; code is the contract.
3. When a report comes in, suspect the connection time, not the deploy time. The answer to "I fixed this yesterday, why is it still happening" is sometimes "that user connected last month." Logging when each client connected makes this call much faster.
4. Put a read in front of anything destructive. The model may be holding an old picture of the world, so making it read current state before overwriting keeps the mismatch from reaching your data.
5. Have a way to ask clients to reconnect. After a large contract change, the old contract keeps running until the user reconnects the connector. You cannot push your way out of that one. It has to be handled as communication.
One last thing
There is plenty written about building MCP servers and not much yet about operating them. The interesting property of this protocol is that the server and the client can live at different points in time, with no error to mark it.
If you have built web APIs for a while this may sound like an old friend: client version skew. But it differs in one decisive way. Ordinary skew is stale code running. This is a model reading stale instructions. Stale code blows up when the signature stops matching. Stale instructions never blow up. They just quietly make a slightly different judgment.
So the question we ask now is: what happens if this sentence is not honored? If the answer is "the reply is a bit less helpful," it stays a sentence. If the answer is "the data goes wrong," it goes into code.
FAQ
Doesn't the client re-request
tools/list on its own?
It varies by client. The spec recommends re-fetching after notifications/tools/list_changed, but that is a SHOULD, and many deployment shapes cannot send the notification in the first place. Some clients re-list when a new conversation starts. The only guarantee is reconnection.Could we skip
instructions entirely and put everything in tool descriptions?
Both have the same problem. The only difference is that the tool-list side at least has an update path defined. Either way, do not assume that deploying changes them.Does this mean prompting is useless for shaping model behavior?
Not at all. Most guidance belongs in descriptions, precisely because a sentence lets the model apply it with judgment. The dividing line is whether that flexibility is a virtue or a risk. If breaking the rule makes an answer slightly less helpful, write a sentence. If breaking it corrupts data, write code.