DEV Community

Cover image for A Read-Only WebMCP Tool Erased My React Form State
Raju Dandigam
Raju Dandigam

Posted on AI-assisted

A Read-Only WebMCP Tool Erased My React Form State

The read path in Trip Workbench can erase a person’s unsaved purpose without sending a write.

In a deterministic reproduction, the returned saved snapshot still says “Workshop.” The edit buffer contains “Leadership meeting” before the read, then loses that value when the response is applied.

That failure is reproducible in Trip Workbench at commit 4b3f370, the public React and TypeScript demo I built and maintain. Its get_trip_draft tool refreshes saved state through the same coordinator that manages the React form. The refresh path uses a merge rule designed for a different operation.

The previous article exposed three WebMCP tools: read, update, and validate. This article follows the read path through Chrome’s debugging tools, reproduces the state loss with controlled API responses, and develops a repair that preserves unsaved work.

The reproduction and repair results below are coordinator-level tests. The repair is a local candidate; the linked repository still contains the original behavior. Native Chrome and model-driven verification remain separate steps.

A read must preserve unsaved work

Trip Workbench keeps two representations of a draft:

  • savedDraft contains the last accepted server snapshot and its revision.
  • localFields contains the values currently displayed in the form.

A person can change localFields.purpose without changing the server. The editor then reports unsaved changes.

The read tool promises to return saved fields, along with revision, status, and hasUnsavedChanges. It deliberately does not expose the unsaved buffer to the agent.

For example, the server can contain “Workshop” while the form displays “Leadership meeting.” A successful read should return “Workshop,” report that unsaved changes exist, and leave “Leadership meeting” on screen.

The WebMCP adapter also declares readOnlyHint: true. An annotation describes intended behavior; application code still has to honor it.

This matters beyond the disappearing text. The update path rejects agent mutations while the editor is dirty. If a read accidentally clears the buffer and marks it clean, a later agent update can pass that check. The read has changed the conditions under which a write is allowed.

The useful invariant is concrete: refreshing saved data must preserve current unsaved values and must never move an accepted saved revision backward.

Trace the read through Chrome

For local development, Chrome documents the flag chrome://flags/#enable-webmcp-testing. Enable it and relaunch the browser. WebMCP is still a proposed standard, so record the browser version alongside any result. See the current setup instructions.

Open a disposable Trip Workbench draft with a saved purpose of “Workshop.” Keep the application’s cloud AI mode disabled if you are testing only the page tools.

In DevTools, open Application → WebMCP. The WebMCP pane lists available tools and records invocations, including their arguments, results, and status. Select get_trip_draft, enter the active draft ID, and use its manual test controls.

Before running it, change the form’s purpose to “Leadership meeting” without saving.

Inspect three things after the call: the returned saved purpose, the unsaved-changes indicator, and the visible input. A successful tool response alone cannot tell you whether the editor preserved the person’s work.

You can also invoke the same tool from the Console:

const context = document.modelContext;
if (!context) throw new Error("WebMCP is unavailable");

const tools = await context.getTools();
const readTool = tools.find(tool => tool.name === "get_trip_draft");
if (!readTool) throw new Error("Open a trip draft first");

const result = await context.executeTool(readTool, {
  draftId: "REPLACE_WITH_THE_OPEN_DRAFT_ID"
});
console.log(typeof result === "string" ? JSON.parse(result) : result);
Enter fullscreen mode Exit fullscreen mode

The current imperative API takes a discovered tool descriptor and an argument object. JSON-stringified input arguments are deprecated from Chrome 155.

Then use the Sources panel to follow get_trip_draft → refreshSavedDraft. Put a breakpoint after the GET resolves and before the coordinator applies its result. Compare:

  • the saved fields before the request;
  • the local fields when it started;
  • the local fields when it completed;
  • the response fields and revision.

Chrome’s logpoints are useful when pausing would alter the sequence you are investigating. Log scalar snapshots such as revision, edit version, and dirty state.

A paused JavaScript thread cannot process ordinary typing in that same page. To test typing during a request, use a controlled response delay or a deferred promise instead of assuming that interacting while paused creates the intended race.

Reproduce the failure without timing guesses

The first failure does not require concurrent requests. The edit exists before the read starts.

The coordinator accepts a fetchImpl dependency, so the smallest reproduction can supply a stable server response. This test fits the repository’s existing Vitest setup:

import { expect, it } from "vitest";
import { createCoordinator } from "../../src/client/lib/coordinator.js";
import { defaultTripFields, type TripView } from "../../src/domain/types.js";

it("keeps an unsaved edit when refreshing the saved draft", async () => {
  const saved: TripView = {
    id: "draft-a",
    revision: 1,
    status: "draft",
    fields: { ...defaultTripFields(), purpose: "Workshop" },
    createdAt: "2026-10-17T07:00:00.000Z",
    updatedAt: "2026-10-17T07:00:00.000Z"
  };

  const coordinator = createCoordinator({
    fetchImpl: async <T>() => ({
      ok: true as const,
      data: structuredClone(saved) as T
    })
  });

  await coordinator.openDraft("draft-a");
  coordinator.updateLocalField("purpose", "Leadership meeting");

  const result = await coordinator.refreshSavedDraft("draft-a");
  const state = coordinator.getState();

  expect(result.ok).toBe(true);
  expect(state.savedDraft?.fields.purpose).toBe("Workshop");
  expect(state.localFields?.purpose).toBe("Leadership meeting");
  expect(state.dirty).toBe(true);

  if (result.ok) expect(result.data.hasUnsavedChanges).toBe(true);
});
Enter fullscreen mode Exit fullscreen mode

In the pinned implementation, the assertion for the local purpose fails: it contains “Workshop.”

The relevant comparison is in mergeAfterInFlightEdit:

const humanChanged =
  input.localNow[key] !== input.localAtDispatch[key];
Enter fullscreen mode Exit fullscreen mode

That comparison asks whether the person changed a field after dispatch.

For this refresh, localAtDispatch.purpose and localNow.purpose both contain “Leadership meeting.” The comparison returns false. The helper keeps the server value, even though the person still has an unsaved edit relative to the saved draft.

The surrounding hadLocalEdits check correctly notices that the buffer is dirty. It cannot repair the helper’s choice of comparison baseline.

This also explains why the existing test for typing during an agent update can pass. An edit made after dispatch differs from localAtDispatch; an edit already present does not. Those are different cases.

Give refreshes and saves different merge rules

A confirmed save acknowledges values that the request submitted. Its reconciliation logic needs to distinguish that submitted work from newer typing.

A refresh acknowledges no local edits. Its job is to update the saved snapshot while preserving differences that remain in the current edit buffer.

For the scalar fields in this demo, a refresh can use three values for each field:

Value Meaning
Base Current accepted saved value
Local Current value in the form
Incoming Value in the newly received server snapshot

If local differs from base, keep local in the form. Otherwise, adopt incoming. If both local and incoming differ from base and disagree with each other, retain the local value as unsaved work and surface the overlap for review.

Here is the candidate helper. It uses the repository’s TripFields type and fieldsEqual function:

function mergeSavedRefresh(
  base: TripFields,
  local: TripFields,
  incoming: TripFields
) {
  const localFields = { ...incoming };
  const overlapped: Array<keyof TripFields> = [];

  for (const key of Object.keys(base) as Array<keyof TripFields>) {
    if (local[key] === base[key]) continue;

    localFields[key] = local[key] as never;

    if (incoming[key] !== base[key] && incoming[key] !== local[key]) {
      overlapped.push(key);
    }
  }

  return {
    localFields,
    dirty: !fieldsEqual(localFields, incoming),
    overlapped
  };
}
Enter fullscreen mode Exit fullscreen mode

Keeping a value in the local buffer does not save it or resolve the conflict. The person still needs to review the changed saved value before deciding what to persist. The candidate adds a notice naming overlapping fields; a field-by-field comparison would make that review easier.

Use the current accepted saved snapshot at response application time as the base. Another operation may have completed while the GET was pending. A local value that was unsaved when the read began may already have been confirmed by a manual save.

This rule treats edits as current value differences. An edit that returns a field to its saved value is no longer an unsaved change. Applications that must preserve edit intent even when values match need additional metadata, such as per-field edit versions.

Reject older snapshots before merging

A correct field merge does not prevent the saved revision itself from moving backward.

The original refresh path also accepts this sequence:

Order Event Accepted saved revision
1 A read begins and will return revision 1 1
2 An agent update commits and its response is applied 2
3 The earlier read returns revision 1 Must remain 2

The server has already advanced. Applying the late read makes the browser display older data.

Keep the existing session, editor, and draft identity checks. After those checks succeed, reject a response whose revision is lower than the currently accepted one. Then merge without another asynchronous boundary:

// After checking response success and the active editor identity.
if (res.data.revision < state.savedDraft.revision) {
  return fail(
    "STALE_SNAPSHOT",
    "A newer saved revision is already displayed."
  );
}

const merged = mergeSavedRefresh(
  state.savedDraft.fields,
  state.localFields,
  res.data.fields
);

setState({
  savedDraft: res.data,
  localFields: merged.localFields,
  dirty: merged.dirty,
  ...(merged.overlapped.length ? {
    message:
      `Saved values changed for ${merged.overlapped.join(", ")}. ` +
      "Your local values remain unsaved; review before saving."
  } : {})
});
Enter fullscreen mode Exit fullscreen mode

STALE_SNAPSHOT is a new error in this candidate. It tells the caller that the read lost a race; the caller can read again. It is distinct from the server’s REVISION_CONFLICT response to a stale write.

This comparison assumes revisions increase for one draft and identify immutable versions of its persisted state. It does not order different drafts, replace identity checks, or establish that a snapshot is globally current.

The same rule covers two reads that complete out of order. A response at revision 2 cannot replace an already accepted revision 3 simply because it arrived later.

Check the repair against specific sequences

I exercised the pinned coordinator with controlled API responses, using Node’s test runner after stripping TypeScript syntax from the source modules. The candidate changed the refresh path and added the dedicated merge helper.

These nine checks produced four passes and five failures on the original implementation, and nine passes on the local candidate:

Check Original Candidate
Preserve an edit made before refresh Fail Pass
Preserve an edit made during refresh Pass Pass
Combine a remote destination change with a local purpose edit Fail Pass
Preserve overlapping local text and show a review notice Fail Pass
Ignore a read older than a confirmed mutation Fail Pass
Avoid resurrecting an edit already confirmed by a manual save Pass Pass
Ignore a response after switching drafts Pass Pass
Preserve typing during an agent update Pass Pass
Prevent out-of-order reads from lowering the revision Fail Pass

The delayed cases use deferred promises: start the operation, perform the intervening state change, and explicitly release the response. That makes the order reproducible without relying on how quickly someone types.

These results establish the coordinator behavior for those sequences. They do not exercise Chrome’s native dispatch, React rendering, the database, or a model choosing tools. The repository’s results document also lists native and model-driven WebMCP as unverified.

Keep cancellation and recovery in the test plan

Tool registration and an individual invocation have separate lifetimes. Chrome’s imperative API supports a registration signal for removing a tool and an execution signal for cancelling a call. Since Chrome 153, unregistering a tool does not cancel its in-flight executions.

That leaves application responsibilities after navigation: a late result must not update a different editor, and cancelling a network request does not establish whether a server mutation committed.

Trip Workbench uses command receipts to resolve uncertain writes. There is a separate issue in recheckOutcome: it creates a new dispatch context from the current buffer when recovery starts. That loses the original comparison baseline. A later receipt can therefore treat edits made while the outcome was uncertain as if they existed at mutation dispatch.

The refresh repair does not address that path. Receipt recovery needs to retain the original mutation context and reconcile against it, with a dedicated test for typing between an uncertain outcome and a recheck.

For React registration, also test mount, cleanup, and remount. Strict Mode’s extra Effect setup and cleanup cycle in development is useful for exposing registration leaks. A clean tool list after remount is one check; rejecting stale in-flight results is another.

Finish with native and model driven checks

Once the coordinator repair is integrated, repeat the disposable-draft exercise through Chrome’s native WebMCP interface. Check the actual input value and unsaved indicator alongside the invocation output. Verify that an attempted agent update still receives USER_EDIT_IN_PROGRESS while the preserved edit remains unsaved.

Then test whether an agent handles that response appropriately. Chrome’s DevTools MCP integration exposes list_webmcp_tools and execute_webmcp_tool when started with --categoryExperimentalWebmcp.

With a draft already containing an unsaved human edit, use a prompt such as:

Inspect the open trip draft. If it has unsaved changes, explain that I need to save or discard them and stop. Otherwise, set the destination to JFK using the returned revision, then validate the updated draft. Stop before submission.

Record whether the agent reads first, recognizes the unsaved state, and stops. For the clean-draft branch, inspect the revision it supplies and the resulting saved fields. A plausible final message is insufficient evidence that the intended sequence ran.

The engineering contract is visible in the form: reading a draft preserves what the person is working on, and a response from earlier work cannot replace a newer accepted state. Those properties have to hold for every caller, including the agent.

Top comments (2)

Collapse
 
johnnylemonny profile image
𝗝𝗼𝗵𝗻 •

That side-effect escalation, where a readOnlyHint: true tool silently clears the dirty flag and unintentionally unlocks the guard for a subsequent agent write, is such a sharp catch.

​Reusing in-flight mutation reconciliation (localNow !== localAtDispatch) for a read refresh is a classic subtle trap: it catches keystrokes made during the network request, completely missing edits that were already sitting in the buffer before dispatch. Separating the 3-way merge (base, local, incoming) and enforcing monotonic revision checks (STALE_SNAPSHOT) is a super clean way to make the read invariant hold. Also love the use of deferred promises to test out-of-order responses deterministically without flaky timing sleeps!

Collapse
 
sherotree profile image
Sherotree •

The savedDraft vs localFields split is what clicked for me. I have shipped the same bug with a refresh-from-server-on-focus path and blamed the form library for weeks. Treating readOnlyHint as documentation instead of a contract is the trap — the merge rule still has to refuse to overwrite dirty localFields.