A business-class trip in my demo produced four facts at the same time:
- the saved fields were valid;
- the request could be submitted;
- the cabin required review;
- nothing had been submitted.
An assistant that compresses those facts into “approved” or “blocked” changes the meaning of the application.
I added prepare_trip_review to Trip Workbench so a WebMCP client can bring the saved request and its findings into the React editor without performing the consequential action. The result explicitly includes submitted: false, and the trip remains a draft.
Make “prepare” a first-class operation
The caller supplies both the open draft identity and the revision it intends to review:
{
"draftId": "<open-draft-id>",
"expectedRevision": 2
}
The native browser trace used a synthetic SFO–JFK request for one traveler in business class. Its captured result included:
{
"revision": 2,
"policyVersion": "demo-policy-v1",
"valid": true,
"canSubmit": true,
"fieldIssues": [],
"reviewRequirements": [
{
"code": "PREMIUM_CABIN",
"message": "Premium cabin requires review."
}
],
"submitted": false,
"status": "draft"
}
This response does not approve a premium cabin, reserve travel, or claim that a human reviewer acted. It reports the demo policy for one saved revision.
That distinction is easier to maintain when review preparation has its own verb. A generic validate call can return data, but prepare_trip_review also places the result where the person can inspect it beside the form.
Bind the review to saved state
The WebMCP adapter parses the arguments and delegates to the same coordinator that owns the editor. That coordinator knows about conditions the server snapshot cannot see:
- unsaved form edits;
- a save in flight;
- an earlier mutation with an unknown outcome;
- the current session and editor generation;
- the exact saved revision visible when preparation began.
Preparation refuses to run when any of those conditions would make the result ambiguous. It also refuses a different draft, a stale revision, or a request that is no longer a draft.
The client stores the result with an explicit binding:
type PreparedReview = ValidationResult & {
preparedAtRevision: number;
submitted: false;
};
Local edits and confirmed mutations clear the panel. The review is not durable truth about the trip; it is a view derived from a particular revision and policy version.
saved revision 2
|
v
validate policy v1 ----> prepared panel
| |
| edit/save/reopen | invalidated
+-----------------------X
Verify tool, page, and database
The recorded run discovered prepare_trip_review through native WebMCP, executed it, waited for the React panel, and independently queried persisted state.
const tool = page.webmcp.tools().find(
(entry) => entry.name === "prepare_trip_review",
);
if (!tool) throw new Error("Review tool was not discovered");
const response = await tool.execute(
{ draftId, expectedRevision: savedRevision },
{ signal: AbortSignal.timeout(6_000) },
);
await page.waitForSelector('[data-testid="prepared-review"]', {
visible: true,
});
The observed surfaces agreed:
| Surface | Observation |
|---|---|
| Tool | revision 2, PREMIUM_CABIN, canSubmit: true, submitted: false
|
| Page | same revision and finding; still a draft |
| Database | revision 2, status draft
|
| Submissions | zero rows |
A second check typed an unsaved purpose before preparation. The tool returned USER_EDIT_IN_PROGRESS, protecting the difference between saved data and what the user currently sees.
Recheck after every await
The first implementation checked the context before calling validation. Controlled race tests showed why that is insufficient: an update could start while validation was waiting, or a user could leave and reopen the same draft before the old response returned.
The reliability pass at 0691767 now verifies the session generation, editor generation, active operation, pending outcome, draft identity, revision, and dirty state after validation resolves. Stale results are refused instead of populating a new panel.
The important pattern is not specific to travel:
const sessionAtStart = sessionGeneration;
const editorAtStart = editorGeneration;
const result = await validate(savedRevision);
if (sessionAtStart !== sessionGeneration) return contextMismatch();
if (editorAtStart !== editorGeneration) return contextMismatch();
if (state.saving || state.pendingOutcome) return refuseStaleResult();
if (state.savedDraft?.revision !== savedRevision || state.dirty) {
return revisionConflict();
}
publishPreparedReview(result);
Any asynchronous result intended for a visible workflow needs a freshness rule. A matching object ID is not enough when the person can leave and reopen that same object.
Keep submission separate
The prepared panel points to the existing review screen. Manual submission sends the reviewed revision and policy version, and the server checks them again.
There is deliberately no submit operation in this five-tool interface. That limits this WebMCP surface; it does not prove that a broader browser agent cannot click an ordinary submit button. For real consequential actions, the current WebMCP specification and Chrome security guidance also define consequentialHint as a signal for actions such as booking travel or transferring money. A signal supports safer client behavior, but server authorization and confirmation rules remain mandatory.
The most useful result of preparation is a controlled pause: the saved request is visible, the finding is tied to a revision, and the person still owns the next consequential action.
Top comments (0)