# Send the change, not the object

*Your MCP server may be undoing your API's own concurrency controls. Four audits measured where, and what to build instead.*

- URL: https://isokron.ai/research/send-the-change-not-the-object
- Published: 2026-11-13
- Author: Joshua Bryan, IsoKron
- Publisher: IsoKron (https://isokron.ai)
- Protocol tested: As in each linked audit. E1: all three servers negotiated 2025-06-18 over stdio; the client applied the 2026-07-28 re-issue rule itself
- Software tested: As in each linked audit. E1: GitHub MCP server v2.1.0 (image pinned by digest), sea-lite 0.7.1 (npm), kubernetes-mcp-server v0.1.2 (commit 66553fed) on a local kind cluster
- Client: E1: a scripted MCP client with a fault-injecting stdio proxy, no model in the loop

> Four audits measured where the MCP layer undid an API's concurrency controls, and what fixed it: field-level writes, version checks, idempotency keys.

*Self-funded research by IsoKron, conducted and written with AI assistance. Not commissioned or endorsed by GitHub, Grafana Labs, PostHog, the maintainers of kubernetes-mcp-server or Red Hat; IsoKron is not affiliated with any of them. IsoKron sells MCP server audits and builds, including rebuilds that follow the rules at the end of this article.*

Two agents changed different fields of one GitHub issue at the same moment through the GitHub MCP server's `issue_write` tool. In 19 of 30 pairs (two calls released together) one change was lost while both calls reported success; through GitHub's own per-field GraphQL mutation, 0 of 28. Same platform; the difference was the shape of the write. In the Kubernetes MCP server, another writer changed `MAX_CONNECTIONS` from 100 to 200 about half a second after an agent read the ConfigMap. Four seconds later the agent's apply set it back to 100, and the agent told the user the other keys were unchanged.

MCP itself has no transaction and, since mid-2025, no JSON-RPC batching. So the tool boundary is where a server keeps or loses three guarantees the backend may already offer: atomicity (a multi-step write happens whole or not at all), concurrency control (a write cannot silently overwrite another writer's change) and retry safety (repeating a write after an ambiguous failure does not apply it twice).

## Part 1: where the protection was lost

In three of the four servers we audited, the MCP layer bypassed or switched off something the API offers to keep concurrent writes apart: Grafana's version check, Kubernetes' field-ownership check (by upstream's choice) and GitHub's per-field mutation, each by a different mechanism. At PostHog the gap was in the API itself; the [four-server study](https://isokron.ai/research/mcp-lost-updates-four-servers) has the side-by-side table.

**Grafana: a forced overwrite, over a check with a gap of its own.** In patch mode, `update_dashboard` in the [Grafana MCP server](https://isokron.ai/research/grafana-mcp-server-audit) v2.2.0 saves the whole dashboard with `Overwrite: true` hard-coded, ignoring the caller's `overwrite` argument, so Grafana's version check never runs. Two patches released together lost an edit in 30 of 30 pairs on Grafana 13 and on Grafana 11, both calls reporting success. When the check could run, Grafana's API rejected a stale save in 10 of 10 trials on each version.

Restoring the check stops a stale save that arrives after another save has landed (the 10 of 10), but not simultaneous saves on the legacy endpoint: two saves with the same version, check on, both went through in 30 of 30 pairs on Grafana 13 and 20 of 30 on Grafana 11 (one rejected in the other 10). Grafana 13's Kubernetes-style API rejected one of the two with a conflict in 30 of 30 pairs and lost nothing. Figure 1 shows all three paths.

**Figure 1: Two simultaneous dashboard patches, by what the save checks**

| Group | Arm | Pairs that lost a change | Pairs with one call rejected (conflict) | Pairs |
| --- | --- | --- | --- | --- |
| Grafana 13 | The MCP tool, v2.2.0 (overwrite forced on) | 30 | 0 | 30 |
| Grafana 13 | Legacy save endpoint, version check on | 30 | 0 | 30 |
| Grafana 13 | Kubernetes-style API, resource version | 0 | 30 | 30 |
| Grafana 13 | Pull request 1518 (saves with the resource version it read) | 0 | 60 | 60 |
| Grafana 11 (legacy save endpoint only) | The MCP tool, v2.2.0 (overwrite forced on) | 30 | 0 | 30 |
| Grafana 11 (legacy save endpoint only) | Legacy save endpoint, version check on | 20 | 10 | 30 |
| Grafana 11 (legacy save endpoint only) | Pull request 1518 (version check, no overwrite) | 14 | 15 | 30 |

Source: the Grafana MCP server audit's GF-01 table and re-runs, as stated in the text.

**Kubernetes: forced apply overrides the field-ownership check, by upstream's choice.** `resources_create_or_update` in [kubernetes-mcp-server](https://isokron.ai/research/kubernetes-mcp-server-audit) v0.1.2 writes with [server-side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/) and `force: true`. That bypasses apply's field-ownership conflicts; the separate `resourceVersion` check still works, but only if the caller keeps the version it read. Upstream chose this on purpose ([issue 799](https://github.com/containers/kubernetes-mcp-server/issues/799)): agents hit ownership conflicts on fields managed by `kubectl` and worked around them by deleting and re-creating objects. We measured it and did not file it. A full copy re-applied without its `resourceVersion` after another writer's change set the field back in 10 of 10 trials for replicas and 10 of 10 for a ConfigMap key. That is the `MAX_CONNECTIONS` revert above: neither native agent session in that audit sent a `resourceVersion`.

**GitHub: the tool bypassed the per-field path.** GitHub's REST API offers no [conditional writes](https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api) for these issue updates, but GitHub does offer safer operations: a per-field GraphQL mutation, `setIssueFieldValue`, for organization issue fields, and REST append and remove endpoints for labels. To set one field, `issue_write` in the [GitHub MCP server](https://isokron.ai/research/github-mcp-server-audit) v2.1.0 reads every field value over GraphQL and writes the whole set back: the 19 of 30 above, against 0 of 28 through `setIssueFieldValue`, which the server's own feature-flagged `set_issue_fields` tool already uses. Labels have the same shape: two sessions that each read the labels and wrote back the set plus one label lost one of the two adds in 9 of 17 simultaneous pairs ([our reproduction upstream](https://github.com/github/github-mcp-server/issues/2724#issuecomment-6733686057)); the append endpoint keeps both.

**PostHog: the gap was in the API.** Two updates to different fields of one insight lost a write in 7 of 11 pairs through the [hosted PostHog MCP server](https://isokron.ai/research/posthog-mcp-server-audit), and in 6 of 11 straight to PostHog's API. The tool sent only the fields it was given; PostHog's update code loaded the row without a lock and saved every column. Feature flags, which lock the row, lost 0 of 11, and the fix does the same for insights: lock and reload the row before applying the request. That fix covers insights only and is merged as [pull request 131620](https://github.com/PostHog/posthog/pull/131620): a maintainer merged a hardened version of our change, with IsoKron as co-author, about 17 hours after we opened ours. Cohorts and dashboards were not yet fixed as of 12 November 2026. A field-level tool helps only when the backend applies fields, not rows. Figure 2 shows both.

**Figure 2: Changing one part of an object: whole-object writes against field-level writes**

| Group | Arm | Pairs that lost a change | Pairs |
| --- | --- | --- | --- |
| GitHub MCP server v2.1.0, two issue fields | The MCP tool (reads every field, writes them all back) | 19 | 30 |
| GitHub MCP server v2.1.0, two issue fields | GitHub's per-field GraphQL mutation | 0 | 28 |
| GitHub MCP server v2.1.0, two issue fields | Patched build (per-field writes) | 0 | 12 |
| GitHub MCP server v2.1.0, two label adds | Read the set, then replace it | 9 | 17 |
| PostHog, two insight fields | Through the hosted MCP server | 7 | 11 |
| PostHog, two insight fields | Straight to PostHog's API, before the fix | 6 | 11 |
| PostHog, two insight fields | Feature flags, which lock the row (control) | 0 | 11 |

Source: the GitHub and PostHog MCP server audits and IsoKron's upstream comment on GitHub issue 2724, as stated in the text.

> **Sidebar: what the protocol gives you.** JSON-RPC batching was added to MCP in the 2025-03-26 revision and removed in 2025-06-18; the removal ([pull request 416](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/416), merged 28 April 2025) saw no compelling use cases and called parallel tool calls straightforward. The current revision, 2026-07-28, has no protocol-level sessions, and when a response stream breaks, clients "MUST re-issue it as a new request with a new request ID" (SEP-2575), so a JSON-RPC id is not an idempotency key. SEP-3182, "Request Idempotency", which would have added one, was closed without merging on 23 August 2026. Annotations such as `idempotentHint` are [hints](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/), untrusted unless the server is trusted. The current tools page defines no batch, transaction or idempotency key.

Recent work maps this boundary formally: Trofimov and Novikov ([arXiv 2609.15397](https://arxiv.org/abs/2609.15397)) catalog eight anomalies that retries, concurrency and partial failures cause at the agent-tool boundary, and find annotation fields on 98,291 tools from registered MCP servers too coarse to express what correctness needs. This article adds measured behavior on four vendor servers, each compared with a control through the vendor's own API.

## Part 2: send the change, and keep the version check

Each fix above was one of three things: send only the change through the vendor's field-level path, carry the vendor's version check through the tool or, where the backend itself lost the write, make its update atomic, as PostHog's row lock does.

**Sending only the change worked.** A patched GitHub build that writes each issue field through GitHub's per-field endpoints lost 0 of 12 pairs; that fix is open as [pull request 3781](https://github.com/github/github-mcp-server/pull/3781). In the Kubernetes concurrent-edit task (F9), agents lost the other writer's change in all 5 runs on v0.1.2 and kept it in all 5 on our research build, which adds `resources_patch`; that task came from our usability sessions and favors the build.

**Keeping the check worked, up to what the backend guarantees.** With its stale `resourceVersion` kept, the same Kubernetes re-apply was refused with a 409 in 10 of 10 trials (straight to the API without force: 409 in 3 of 3). `resources_scale`, which sends the `resourceVersion` it read, returned an injected conflict with nothing changed in 3 of 3 trials and surfaced real contention in 10 of 10. Grafana's [pull request 1518](https://github.com/grafana/mcp-grafana/pull/1518), open, saves through the Kubernetes-style API with the resource version it read: on Grafana 13 it lost 0 of 60 pairs, returned a conflict in every pair, and a single retry landed both edits in all 60. On Grafana 11, with only the legacy endpoint, it lost 14 of 30, returned a conflict in 15 and kept both edits in 1. A precondition gives the tool the backend's guarantee and no more.

**A check can also be faked.** Grafana's full-body save for v2 dashboards copies the current resource version onto the caller's body (GF-19), so a body read earlier replaced an intervening edit in 3 of 3 trials on Grafana 13, while a direct `PUT` with the stale version got a 409 in 3 of 3. When the caller sends no version, a server can read the object, apply the change to what it read and write with that version; it must never stamp the current version onto a body the caller read earlier.

In the audit's two native sessions, no agent added a precondition: the Kubernetes apply tool accepts a `resourceVersion` inside the manifest, and neither session sent one. When the precondition is an explicit, optional argument, the picture is mixed.

> **Exploratory, single run.** From one Kubernetes benchmark series (12 November 2026), not repeated or published in the audit. With `resourceVersion` optional on the research build's `resources_patch`, Claude Opus 5.5 sent it on 20 of 20 calls (F9 10, K2 5, K4 5) and Claude Sonnet 5.5 on 0 of 25 (K2 5, K5 18, K6 2); on the same task, K2, that is 5 of 5 calls against 0 of 5. In F9 each of the 5 Opus runs had one patch refused (409 Conflict in the API server's audit log), then one accepted with a newer `resourceVersion`. The other writer had changed a different key, so a one-key merge patch would not have lost it: the check cost a refusal and a retry per run. We have not yet measured a version pinned by default.

## Batch and multi-get: fewer calls, and what they cost

Batching cuts calls, not lost updates. Without batch tools, a job costs a call per object: 30 and 35 calls for two bulk PostHog jobs, 60 for a Kubernetes clean-up in which the agent tried 33 kinds one by one, and 5 MCP calls for a GitHub review that GitHub's REST API takes in 1.

The research builds added batch tools (PostHog's validate every item first, write best effort with one result per item, and say they are not all-or-nothing), batch and multi-get operations (Kubernetes), and a multi-file read and one-call review (GitHub). Each was a combined intervention, so no single change's effect is isolated. Figure 3 shows the results.

**Figure 3: Median tool calls per run where a batch or multi-read tool did the work (combined builds)**

|  | Before | After |
| --- | --- | --- |
| PostHog, bulk tagging and annotations (task h, friction-derived) | 28 | 10 |
| Kubernetes, selective clean-up (K6, pre-registered) | 36 | 8 |
| GitHub, rename across 3 files (multi-file read) | 22 | 14 |

Source: the PostHog, Kubernetes and GitHub audits' benchmark tables, as stated in the text.

- **PostHog, task h** (3 runs per build; friction-derived, so it favors the build): 28 to 10 calls, tool-result bytes −76.8%. Two of the build's four failed calls on the pre-registered tasks (18 runs per build) were its own guard rails; nothing ran.
- **Kubernetes, K6** (5 runs per build; pre-registered): 36 to 8 calls, 145 of the 173 fewer calls across the six tasks.
- **GitHub, rename** (3 runs per build): 22 to 14 calls, mostly from the multi-file read (84 fewer `get_file_contents` calls over three jobs; 321 to 244 calls over 12 runs per build). The one-call review cut review writes from 11 to 6 per run.

**What batching did not buy, and what it cost.** Kubernetes' accounting model, not an ablation, credits batch and multi-get with −2.0% of tokens but −9.2% of cost per verified success on the pre-registered set (+2.5% and −12.8% on the friction-derived set). The bigger catalog (10,499 cl100k tokens against 6,807, 54% more) cost 43.7% and 44.0% more tokens per success on the single-object tasks K1 and K2 (5 runs each), and PostHog's compound `dashboard-create-with-insights` was called 0 times in either task set.

> **Exploratory, single run.** On the Kubernetes friction-derived task F7 (read many named objects), Opus used a mean of 9 calls on v0.1.2 and 1 on the research build over 5 runs each, in the same benchmark series as the box above.

## Partial failures and retries

### A partial write must say what happened

Every partial-write defect we found was a result that did not say what had happened. A three-document kubernetes-mcp-server apply with the second document invalid wrote the first, never sent the third and named only the invalid one, in 3 of 3 runs (K8S-01), where `kubectl apply --server-side` applied the first and third and reported the second. Two separator shapes made an apply write only the first document and report success, 3 of 3 each. `pods_delete` reported success when deleting its Service was refused, and `resources_delete` said "deleted successfully" while a finalizer held the object, 3 of 3 each. In GitHub, a failed `push_files` leaves its new branch behind (GH-04), and pull request creation can succeed and then return an error, losing the new number (GH-05).

Two designs report honestly. All-or-nothing fits where the backend has a transaction: [sea-lite](https://github.com/isokron-ai/sea-lite), our own work queue, writes a batch of tickets and links that way, as [DynamoDB's `TransactWriteItems`](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html) does. Best effort with one result per item fits where it does not, as [`BatchWriteItem`](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchWriteItem.html) returns what it could not process. The Kubernetes research build dry-runs every document before writing any, which prevented the partial writes we tested but is not a transaction: the cluster can change in between. Which design leads an agent to a correct end state after one item fails is not yet measured.

### Retries after a lost response

Under the 2026-07-28 rule, a client whose response stream breaks re-sends the call with a new request id; if the backend had already accepted the write, the server sees two identical calls. The GitHub audit saw this duplicate an issue but did not count it; E1 counted it on 12 November 2026.

**Method.** A stdio proxy dropped the response to the first create call in each trial. The server writes that response only after its backend call returns, so the backend had always accepted the write. A scripted client, with no model in the loop, waited 15 seconds and re-issued the same call once with a new JSON-RPC id. Objects were counted through each backend directly (GitHub's REST API, sea-lite's list tool, `kubectl`), and a final sweep recounted every trial. Each arm ran 30 trials, 150 in all, none excluded. The design was registered before any trial, with one addendum before the counted trials (the GitHub count method). Intervals are exact 95% binomial intervals. Figure 4 shows the five arms.

**Figure 4: Retrying a create after its response was lost (E1, 30 trials per arm)**

|  | No duplicate | Duplicate created |
| --- | --- | --- |
| A. GitHub MCP server: plain retry | 0 | 30 |
| B. GitHub MCP server: list issues first, retry only if missing | 30 | 0 |
| C. sea-lite: idempotency key (IsoKron's reference implementation) | 30 | 0 |
| C0. sea-lite: same retry, no key | 0 | 30 |
| D. kubernetes-mcp-server: client-named ConfigMap | 30 | 0 |

Source: the E1 results in the text.

- **A plain retry duplicated.** After we dropped the response to a GitHub MCP server `issue_write` create that GitHub had accepted, re-issuing it with a new request id, as the 2026-07-28 spec requires, produced a second issue in 30 of 30 trials (95% CI 88–100%). Each retry returned a new issue number with no sign of a duplicate. The tool's `idempotentHint: false` is correct and prevents nothing.
- **Reading before retrying worked here.** A client that listed the repository's issues through the same server, and retried only if the issue was missing, produced no duplicate in any of 30 trials (95% CI 0–12%), because the read came 14 seconds after the write.
- **An idempotency key worked.** On [sea-lite](https://github.com/isokron-ai/sea-lite), IsoKron's own MCP server and our reference implementation (not a vendor's), the same retry with an idempotency key created no duplicate in 30 of 30 trials; every retry replayed the first result, with the same ticket id and `idempotent_replay: true`. Without the key, it duplicated the ticket in 30 of 30.
- **A client-chosen name acts as a key.** kubernetes-mcp-server's `resources_create_or_update` re-applied the same ConfigMap in 30 of 30 retries, with the same uid and `resourceVersion`, and created no duplicate.

**Reads lag.** About 3.5 seconds after the retry in arm A, GitHub's REST issue list showed only one of the two issues in 19 of 30 trials, and both within about 10 seconds in every trial; a pilot read at 3 seconds undercounted, so the count polls until every reported issue is listed. A read-before-retry made sooner, or through a lagging path, can miss a write that has happened and duplicate it.

**Limits.** The rates are conditional on a lost response after the backend accepted the write, not rates in normal use; arm A, where nothing can deduplicate, shows a mechanism, not a probability. All three servers negotiated 2025-06-18, so the client applied the 2026-07-28 re-issue rule itself; a 2026-07-28 server would see the same two calls. One client, one retry policy (15-second timeout, one retry), no concurrent writer. Arm C shows a key working on our own server, not any vendor's, and did not test a key reused with different arguments. Arm D's name acts as a key, but the write is not safe: the same forced apply overwrote another writer's change in the audit. Arm D ran as the cluster admin because the audit's service-account token had expired; the count does not depend on identity.

## The rules we build to

**Figure 5: How we choose the shape of a write**

1. **Changes part of an object?.** Send only that part, through the vendor's field-level path, addressed by id.
2. **Depends on the old value?.** Carry the version that was read, and return a conflict instead of overwriting.
3. **Touches many objects?.** One batch call with a result per item, and a description that says all-or-nothing or best effort.
4. **Can a step fail after another succeeded?.** Use a backend transaction where one exists; otherwise validate first and report exactly what was written.
5. **Can it be retried after a timeout?.** Take an idempotency key and replay the first result.

Source: the rules below.

Figure 5 is the short form; each rule names its evidence or says it is not yet measured.

1. **Send the change, not the object.** Use the vendor's field-level path; never write back a whole set, or build a full replace from a partial request (that reset Grafana's omitted alert-rule fields in 3 of 3, GF-03). *Evidence:* GitHub 19 of 30 against 0 of 28 ([Part 1](https://isokron.ai/research/send-the-change-not-the-object#part-1-where-the-protection-was-lost)). *Status:* Measured.
2. **Address by identity, not position:** an index-addressed Grafana patch hit the wrong panel after another writer's insert in 5 of 5 (GF-02). *Status:* Measured.
3. **Carry the vendor's precondition; never force past it or stamp the current version onto a stale body.** Without one from the caller, write with the version just read. *Evidence:* Grafana 30 of 30 with overwrite forced; GF-19 3 of 3 ([Part 2](https://isokron.ai/research/send-the-change-not-the-object#part-2-send-the-change-and-keep-the-version-check)). *Status:* Measured; a version pinned by default is not yet measured.
4. **Precondition where the new value depends on the old one; field-level operations where it does not.** *Evidence:* one 409 and one retry per F9 run for disjoint keys (exploratory). *Status:* Partly measured; same-key and derived-value edits not yet measured.
5. **Batch tools: one call per kind of change, items validated first, one result per item with a stable id, the contract stated, destructive batches confirmed.** *Evidence:* K6 36 to 8 calls ([batch section](https://isokron.ai/research/send-the-change-not-the-object#batch-and-multi-get-fewer-calls-and-what-they-cost)). *Status:* Measured on combined builds; which contract agents recover from best is not yet measured.
6. **All-or-nothing where the backend has a transaction; otherwise validate first, report what was written and not attempted, and never call a compensating rollback atomic.** *Evidence:* K8S-01 3 of 3 ([partial failures](https://isokron.ai/research/send-the-change-not-the-object#a-partial-write-must-say-what-happened)). *Status:* Defects measured; designs not yet compared.
7. **Multi-get for reads; batch reads only, so a write never rides along unseen.** *Evidence:* 84 fewer GitHub file reads. *Status:* Measured on combined builds; read-only batching's own effect not isolated.
8. **Idempotency keys on every create and non-idempotent write,** bound to the request: a replay returns the first result, a reused key with different arguments is an error, scope and retention are stated. *Evidence:* E1, 30 of 30 duplicated without a key, 0 of 30 with one ([retries](https://isokron.ai/research/send-the-change-not-the-object#retries-after-a-lost-response)). *Status:* Measured on our reference implementation; key reuse and retention not yet measured.
9. **Annotations that match the write, `idempotentHint` never taken for deduplication, unknown arguments refused.** *Evidence:* `dryRun: true` ignored, a real write in 3 of 3 ([Kubernetes audit](https://isokron.ai/research/kubernetes-mcp-server-audit)); E1 arm A. *Status:* Measured.
10. **Receipts that make the next write safe:** id, version after the write, each changed field from and to. *Evidence:* 73 PostHog writes and deletes returned 61,363 tokens where 4,187 were needed ([PostHog audit](https://isokron.ai/research/posthog-mcp-server-audit)). *Status:* Measured.
11. **Pay for catalog size only where it saves.** *Evidence:* K1 and K2 used 43.7% and 44.0% more tokens per success. *Status:* Measured.

We build to these rules ([MCP server builds](https://isokron.ai/mcp-development)) and audit against them; we do not present an audit of code we built as independent.

### What this does not show

- **Small counts.** 11 to 30 pairs per cell, 10 trials per Kubernetes arm, 3 to 5 runs per benchmark task, 30 trials per E1 arm. Deterministic code paths (3 of 3, 10 of 10) show a mechanism, not a rate; only the simultaneous pairs are races.
- **No natural collisions.** Every benchmark run was one agent alone; the lost-update counts come from scripted pairs, and the E1 rates are conditional on a dropped response. None of these numbers says how often this happens in normal use.
- **Combined builds and favorable tasks.** Each research build changed many things, and the per-type shares come from an accounting model, not ablations. PostHog's friction-derived set and Kubernetes set B favor the research builds by construction. The research builds are research, not releases.
- **One client per audit and four servers,** chosen because they are widely used and open source, not at random.

Each audit's method, controls and limits are in the [GitHub](https://isokron.ai/research/github-mcp-server-audit), [Grafana](https://isokron.ai/research/grafana-mcp-server-audit), [PostHog](https://isokron.ai/research/posthog-mcp-server-audit) and [Kubernetes](https://isokron.ai/research/kubernetes-mcp-server-audit) articles and the [four-server study](https://isokron.ai/research/mcp-lost-updates-four-servers). The research builds are public: [GitHub](https://github.com/isokron-ai/github-mcp-writepath-experiment), [Grafana](https://github.com/isokron-ai/mcp-grafana/tree/iso/write-path), [PostHog](https://github.com/isokron-ai/posthog-mcp-writepath-experiment), [Kubernetes](https://github.com/isokron-ai/kubernetes-mcp-writepath-experiment).

### Check your own server

1. Race two writes to different parts of one object from two server processes, at least ten times, and repeat the same two writes straight to the vendor's API as a control.
2. Write from a stale read and check that the tool refuses it.
3. Send a batch with one bad item and read back every item.
4. Drop the response to a create, retry with a new request id, and count the objects through the vendor's API, polling until the count settles.
5. Compare each tool's annotations with what the tool does.

The full method is in [How to test an MCP server without fooling yourself](https://isokron.ai/research/how-to-test-an-mcp-server).

## Sources

- MCP specification changelogs: [2025-03-26](https://modelcontextprotocol.io/specification/2025-03-26/changelog) (batching added), [2025-06-18](https://modelcontextprotocol.io/specification/2025-06-18/changelog) (batching removed), [2026-07-28](https://modelcontextprotocol.io/specification/2026-07-28/changelog) (re-issue with a new request id, SEP-2575); the [2026-07-28 tools page](https://modelcontextprotocol.io/specification/2026-07-28/server/tools).
- [Pull request 416](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/416), "Remove batching requirement"; [SEP-3182](https://github.com/modelcontextprotocol/modelcontextprotocol/pull/3182), "Request Idempotency", closed without merging.
- MCP blog, [Tool Annotations as Risk Vocabulary: What Hints Can and Can't Do](https://blog.modelcontextprotocol.io/posts/2026-03-16-tool-annotations/), 16 March 2026.
- Trofimov and Novikov, [When Tool Calls Succeed but Workflows Fail: Anomalies at the Agent-Tool Boundary](https://arxiv.org/abs/2609.15397), arXiv 2609.15397, September 2026.
- Kubernetes: [API concepts](https://kubernetes.io/docs/reference/using-api/api-concepts/) and [server-side apply](https://kubernetes.io/docs/reference/using-api/server-side-apply/). Grafana: [HTTP API for the Kubernetes-style resources](https://grafana.com/docs/grafana/latest/developer-resources/api-reference/http-api/apis/). GitHub: [REST API best practices](https://docs.github.com/en/rest/using-the-rest-api/best-practices-for-using-the-rest-api), on conditional requests.
- Idempotency keys: [Stripe](https://docs.stripe.com/api/idempotent_requests), the [AWS Builders' Library](https://aws.amazon.com/builders-library/making-retries-safe-with-idempotent-APIs/) and the IETF [Idempotency-Key header draft](https://datatracker.ietf.org/doc/draft-ietf-httpapi-idempotency-key-header/). Batches: DynamoDB [`TransactWriteItems`](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_TransactWriteItems.html) and [`BatchWriteItem`](https://docs.aws.amazon.com/amazondynamodb/latest/APIReference/API_BatchWriteItem.html). HTTP preconditions: [RFC 9110](https://www.rfc-editor.org/rfc/rfc9110.html), section 13.1.1.

## Changelog

- **13 November 2026:** published.

---

HTML version: https://isokron.ai/research/send-the-change-not-the-object

**How this was tested.** AI agents run much of the testing: scripted checks, attempts to disprove each finding, and real tasks that use the server the way an agent would. I also test every server myself on a separate machine and check each finding against the AI results. A finding appears here only if it holds up in both. — [Joshua Bryan](https://isokron.ai/about)

For this piece: Every IsoKron figure comes from the four linked audits, the four-server study, the E1 retry test or the Kubernetes benchmark records named in the text; figures not published before are labeled exploratory. The five refused patches in the exploratory box were checked against the Kubernetes API server's audit log, which records 409 Conflict for each. Outside sources under Sources were checked on 12 November 2026.
