Skip to content

MCP server: default-toolset compatibility (foundations, canonical schemas, full default tools)#105

Merged
suchintan merged 7 commits into
mainfrom
feat/mcp-gap-foundations
Jul 21, 2026
Merged

MCP server: default-toolset compatibility (foundations, canonical schemas, full default tools)#105
suchintan merged 7 commits into
mainfrom
feat/mcp-gap-foundations

Conversation

@suchintan

@suchintan suchintan commented Jul 19, 2026

Copy link
Copy Markdown
Member

Builds the MCP server's default-toolset compatibility (stacked on the remote-CDP branch of #93 — that commit appears here until #93 merges):

  1. Foundations — typed session state with an exactly-once per-page handler registrar (console/network/filechooser/dialog/download), a dedicated event lock, navigation epochs, bounded immutable event records; a safe file policy (RUSTWRIGHT_MCP_OUTPUT_DIR confinement, 0700/0600, byte caps, post-write integrity checks — screenshot paths outside the root now return a structured error, a documented breaking change); a strict ref-or-selector resolver (refs never fall through to CSS; selectors must match exactly one element); serverInfo now reports the package name/version; a clean-room contract-comparator harness.
  2. Canonical schemas — the ten schema-divergent default tools advertise canonical camelCase parameters with snake_case accepted as hidden aliases (canonical wins on conflict); every advertised tool rejects unknown parameters (additionalProperties: false); a sectioned response envelope (Result/Page/Tabs/Snapshot); --caps/env acceptance (warn, never crash); RUSTWRIGHT_MCP_TOOLSET=mirror|lean profiles; browser_evaluate is registered by default with a RUSTWRIGHT_MCP_ALLOW_EVAL=0 opt-out (SECURITY note in mcp/README.md).
  3. Docs — copy-paste installs per client (Claude Code / Claude Desktop / any MCP client), a decision table, and a 60-second CLI quickstart.
  4. Default toolset completion — resize, drag, fill_form, find, console_messages, network_requests/network_request (session-monotonic indices), file_upload; dialogs use pending-modal semantics across all tabs with a no-hang guarantee; automatic downloads are reported exactly once; envelope Console/Modal/Downloads sections; the contract fixture covers the full default set.

Verification

120 tests, run twice, all green — covering cross-tab modal handling, index stability across navigations, out-of-order download completion, path-escape and symlink-swap attempts, alias-conflict resolution, and exact fixture-vs-schema checks. The adversarial regression suites are retained under mcp/tests/validation/.

Review updates

Addressed review feedback: the file-chooser retry now binds to the originating input element's identity (no double-executing an unrelated click); symlink-escape cleanup removes only the in-root link, never the resolved external target (a sentinel test asserts the external file survives); the file policy tracks only paths it creates, so a user-configured output dir's pre-existing files are never chmodded or evicted; a context-level page handler covers window.open popups from birth; RUSTWRIGHT_MCP_ALLOW_EVAL parses strictly and fails loudly on unknown values; the contract comparator now pins the exact normalized tools/list (all 25 tools + full schemas + required sets) with an explicit additions allow-list; navigation eviction counters are bounded.

Notes for reviewers

  • The screenshot path confinement and the eval default are deliberate, documented behavior changes (see mcp/README.md SECURITY + File outputs sections).
  • The contract fixture (mcp/tests/contract/fixtures/default_toolset.json) is clean-room: names/types/enums/defaults/required flags only.

@marcmuon marcmuon left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a genuinely strong increment — I ran the full suite twice locally (113 passed, both runs; the body's "105 tests" is stale, and the branch carries a fifth browser_drop commit the description doesn't mention), and the foundations are the real thing: the file policy does O_EXCL/O_NOFOLLOW creation, symlink-component parent walks, and lstat/realpath re-checks; the resolver's honor-or-error behavior held up under probing (unknown and misspelled keys rejected at top level and nested; canonical wins alias conflicts); and I runtime-verified the eval opt-out fully unregisters the tool. Test quality is real — hundreds of specific assertions, not vacuous checks.

Withholding approval for two fix-first items, both verified against the code and both worth fixing in this PR: the upload-failure retry flag that can make an unrelated click execute twice (a determinism break, which is this project's core promise), and the escape-cleanup path that deletes the file a hostile symlink pointed at (destructive cleanup outside the confinement root). Both have small fixes; details inline. The remaining comments are hardening/completeness — the kind of thing worth catching while the toolset surface is still young.

One more body-level note: CI never runs mcp/tests/ (the workflow's test list doesn't include it and root pytest discovery is scoped to tests/), so all 113 of these tests are green only on contributors' machines. Wiring them into the test workflow is probably the single highest-leverage follow-up in this PR.

Comment thread mcp/rustwright_mcp/server.py Outdated
_, chooser = _pending_modals(page)
if chooser is None:
# A cancelled chooser after a validation failure is suppressed once
# by some backends. Replay this already-authorized input click once.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix before merge — determinism: file_chooser_retry_needed is a page-wide flag set by a failed upload, and the next browser_click on that page replays its own action if no chooser appears — without verifying the target is the file input the flag was set for. Sequence: upload validation fails → flag set → agent clicks any unrelated button → that button is clicked twice while the tool reports one success. A double-executed action on a stateful page (submit, pay, delete) is the exact class of bug this surface exists to prevent. Minimal fix: bind the retry to the originating chooser's input element identity (replay only when the incoming click resolves to that same element), or drop the automatic replay and surface a retry hint instead.

Comment thread mcp/rustwright_mcp/filepolicy.py Outdated
and resolved is not None
and not _is_within(resolved, self.output_root)
):
self._remove_entry(resolved)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fix before merge — destructive cleanup: when finalize_output detects a symlink whose resolved target escaped the root, this branch unlinks the resolved external target — i.e. the policy deletes a file outside its confinement root, which is precisely what confinement promises never to touch. If the symlink pointed at a pre-existing user file, cleanup destroys it (and the adversarial test can't see this because it removes the evidence it should assert survives). Fix: remove only the in-root symlink entry, never the resolved external path — and add a sentinel test asserting a pre-existing external target survives the escape attempt unchanged.

root = Path(output_root).expanduser()
if not root.is_absolute():
root = Path.cwd() / root
root.mkdir(mode=0o700, parents=True, exist_ok=True)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pointing RUSTWRIGHT_MCP_OUTPUT_DIR at an existing directory makes the policy chmod 0700 it and — via _artifact_files() rglob — treat every pre-existing regular file beneath it as an evictable artifact when the total-byte cap is exceeded. A user who sets the env var to ~/Screenshots can have unrelated files silently deleted by quota eviction. Suggest a per-session subdirectory under the configured root (keeps the UX, scopes chmod and eviction to files this policy created), or track created paths and evict only those.

self.page_registries[key] = registry
return registry

def register_page_handlers(self, page: Any) -> PageRegistry:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Popup coverage gap: handlers are installed only at explicit registration sites (navigate/tabs/etc.) — there's no context-level page handler — so a window.open popup's early console/network/dialog/download events are lost until some tool call happens to touch it, and a dialog raised by a fresh popup can sit outside the pending-modal system the no-hang guarantee depends on. The existing multi-tab tests register their second page manually, which masks this. Fix: register a context-level page-created handler at attach time (and remove it at teardown) so every page is covered from birth.



def _allow_eval() -> bool:
raw = os.environ.get("RUSTWRIGHT_MCP_ALLOW_EVAL")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Runtime-verified: RUSTWRIGHT_MCP_ALLOW_EVAL=0 cleanly unregisters browser_evaluate — but the gate fails open for anything not in the falsy set, including typos (ALLOW_EVAL=flase → tool enabled). For a security toggle, unknown values should be rejected loudly at startup (the RUSTWRIGHT_MCP_TOOLSET parser right below already warns on unknown values — same treatment, or stricter). Registered-by-default is a defensible parity call given the SECURITY note; fail-open on misconfiguration is harder to defend.

return None


def compare_tool_schema(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comparator makes this fixture a compatibility floor, not a wire-format pin: extra tools and extra optional params pass silently, fixture-required fields aren't required to stay required, and nested schemas/additionalProperties aren't compared. Concretely, the fixture pins 23 tools while the default inventory registers 25 (browser_reload and browser_get_text are unpinned), so a schema regression in those — or any additive drift — sails through. Since the whole point of the clean-room fixture is catching unintended surface change, suggest comparing the exact normalized tools/list output (names, full schemas, required sets) with an explicit allow-list for intentional additions.

Comment thread mcp/rustwright_mcp/session.py Outdated
navigation_start_request_index: int = 1
navigation_pending: bool = False
last_known_title: str | None = None
console_evictions: dict[int, int] = field(default_factory=dict)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small leak for the long-lived-session case: console_evictions/network_evictions are keyed by navigation epoch and incremented on every ring-buffer eviction, but _begin_navigation bumps the epoch without ever pruning old keys — so a busy session accumulates one dict entry per navigation forever. network_evictions is only ever read for the current epoch, and console_evictions history is only used as a sum, so the history is pure dead weight. Fix is one line each: prune stale epochs in _begin_navigation (network), and fold console into a running total. Worth doing since long-lived parallel sessions are exactly this server's target workload.

@suchintan
suchintan requested a review from marcmuon July 20, 2026 19:26

@marcmuon marcmuon left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All seven review items verified on this head — approving. The two fix-before-merge items check out in code: chooser retry binds to the originating input's element identity so an unrelated click can no longer double-execute, and escape cleanup takes a lexical-containment guard with a byte-exact sentinel test proving an external target survives untouched. Strict eval parsing, created-paths-only eviction scoping, context-level popup registration, the 25-tool contract pin, and bounded counters all landed. Suite: 120 passed, run twice, on db5bcb1. Disclosure: an independent review lane rates the residual identity-check race (inline below) and same-path file replacement inside the output root as blocking; I don't — the replacement scenario requires a same-UID writer inside the 0700 root, which is outside this policy's threat model, and the race is a narrow hardening item with the one-line fix suggested inline.

retry_same_file_input = False
if retry_element is not None:
try:
retry_same_file_input = bool(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Non-blocking hardening: the identity check and the replay both go through the live Locator, which re-resolves by selector — so a DOM swap in the ~50ms between check and replay could still hit a different node. Clicking the replay through the captured element handle instead closes that window and fails loudly (detached handle) rather than clicking the wrong element, which is the right failure mode for this surface.

suchintan and others added 7 commits July 21, 2026 10:54
Add RUSTWRIGHT_MCP_CDP_ENDPOINT / _CDP_HEADERS / _CDP_TIMEOUT_MS so the
server can attach to an externally-owned browser over CDP instead of
launching a local Chromium. In remote mode the local launch options are
ignored, teardown detaches without terminating the remote browser, and a
dead or unreachable remote fails loudly — it never silently falls back to a
local browser. Endpoint and headers are never printed or logged. Docs cover
the vendor-neutral usage with a hosted provider (x-api-key header) as an
example; tests exercise a live remote (a second local browser) and a
dead-endpoint failure.

Co-Authored-By: Claude <noreply@anthropic.com>
…er, contract harness

Groundwork for default-toolset compatibility (no tool behavior change yet
except the documented screenshot-path confinement):

- Typed SessionState with an exactly-once per-page handler registrar (console,
  request/response, filechooser, dialog, download) at every page-acquisition
  point; a dedicated event lock so callbacks never contend the tool lock;
  navigation epochs and stable 1-based per-epoch request indices; bounded
  immutable event records with oldest-first eviction; modal/download slots.
- Safe file policy: outputs confined to RUSTWRIGHT_MCP_OUTPUT_DIR (0700 dir,
  0600 files, exclusive create, per-file and total byte caps with eviction,
  workspace-relative artifact links). Post-write finalization rejects a
  non-regular or escaped output. browser_take_screenshot is migrated to it;
  a path outside the root now returns a structured error instead of writing
  (documented breaking change; see mcp/README.md).
- Strict ref-or-selector resolver: refs resolve only via the current snapshot
  registry (never fall through to CSS); selectors must match exactly one node.
- serverInfo now reports the package name and version (was the SDK version).
- Neutral contract-comparator harness (clean-room fixture: names/types/enums/
  defaults/required only) seeded with the already-matching tools.

43 tests pass, including an independent reviewer's adversarial regressions
(finalization integrity, workspace-relative links, evicted-record handling).
…files

Wire-level compatibility for the ten schema-divergent default tools, with
canonical camelCase parameters advertised and snake_case accepted as hidden
aliases (canonical wins on conflict). All advertised tools now reject unknown
parameters (additionalProperties: false; honor-or-error).

- click: element/doubleClick/button/modifiers; type: submit/slowly (clear kept
  as a documented extension); select_option: values[] (scalar normalized);
  snapshot: target/filename/depth/boxes (integer enclosing-bounds geometry;
  zero-area nodes unannotated); screenshot: element/target/type/filename/
  fullPage/scale with mutual-exclusion checks; evaluate: function alias,
  element-context execution, JSON results; handle_dialog: promptText alias;
  wait_for: time (seconds, capped 30) / textGone; tabs: action enum,
  close-current default, list in every response.
- Sectioned response envelope (Result / Page / Tabs / Snapshot), one snapshot
  per mutating call, no codegen section.
- RUSTWRIGHT_MCP_CAPS env + --caps argv accepted (warn, never crash);
  RUSTWRIGHT_MCP_TOOLSET=mirror|lean profiles.
- browser_evaluate is now registered by default; RUSTWRIGHT_MCP_ALLOW_EVAL=0
  disables it (SECURITY note in mcp/README.md; top-level README aligned).
- Clean-room contract fixture extended to all ten adapted tools.

85 tests pass, including an independent reviewer's adversarial suite
(alias conflicts, schema purity, unknown-parameter rejection, box geometry,
caps precedence, envelope ordering).
MCP section now leads with zero-edit setup blocks for Claude Code, Claude
Desktop (with config-file locations), and any MCP client, plus a three-row
decision table (bundled browser / headed / disabling eval) and the upcoming
PyPI shorthand. CLI section gets a try-it-in-60-seconds loop. mcp/README.md
puts install + verify on the first screen.
…loads

Adds the remaining default tools and the observability/modal/download layer:

- browser_resize (number dimensions, device-pixel rounding), browser_drag,
  browser_fill_form (five field types, first-failure by field name,
  documented non-transactional), browser_find (snapshot-backed, actionable
  refs, /pattern/flags regex with i,m,s).
- browser_console_messages (severity threshold, per-navigation scoping,
  artifact output) and the network pair: browser_network_requests
  (session-monotonic indices — never reused across navigations; static-asset
  filtering; regex filter preserving indices) + browser_network_request
  (per-part detail, lazy bounded body reads, base64 for binary,
  stale-navigation indices rejected with the valid range).
- browser_file_upload on pending file-chooser modals (workspace-confined
  paths, cancel via omission), and dialogs flipped to pending-modal
  semantics across ALL tabs (no arm-next; no-hang guarantee: tools report
  modal state instead of evaluating on blocked pages; tabs list uses cached
  titles).
- Automatic downloads (sanitized names, caps) reported exactly once each via
  per-download flags, plus envelope sections Console (new-since-last,
  warning+, capped) / Modal / Downloads.
- Clean-room contract fixture extended to the full default set.

105 tests pass twice, including two independent adversarial validation
suites (cross-tab modals, index stability across navigations, out-of-order
download completion, fixture-vs-target-schema checks).
…fixtures

Completes the default toolset. Validates sources via the shared file policy
(workspace-confined, non-symlink, byte-capped), synthesizes a DataTransfer
with File objects and MIME string entries, and dispatches
dragenter/dragover/drop on a strictly-resolved unique target. The synthetic
nature (untrusted events) is disclosed in the tool description. A
machine-readable conformance artifact records observed behavior across four
dropzone styles for the future native-drop decision. Mirror profile only.

Independently validated: file content fidelity via FileReader, mixed
files+data, custom MIME retrieval, preventDefault-style handlers,
reject-path security (no page-world leakage on rejects).
… scoping, popups, eval toggle, exact contract, counter bounds

Seven review fixes (two 'fix before merge'):
- File-chooser retry now binds to the originating input element's identity and
  replays only when the incoming click resolves to that exact element; otherwise
  no replay (fixes double-executing an unrelated side-effecting click).
- Symlink-escape cleanup removes only the in-root link entry, never the resolved
  external target; sentinel test asserts a pre-existing external file survives
  byte-for-byte (the prior adversarial test wrongly required its deletion).
- FilePolicy tracks only paths it created; chmod 0700 and eviction never touch
  pre-existing files under a user-configured RUSTWRIGHT_MCP_OUTPUT_DIR.
- Context-level page handler registers every popup (window.open) from birth, so
  its early console/network/dialog/download events and the no-hang guarantee hold.
- RUSTWRIGHT_MCP_ALLOW_EVAL parses strictly (1/true/yes/on|0/false/no/off);
  unknown values fail loudly at startup instead of silently enabling eval.
- Contract comparator now pins the exact normalized tools/list (names + full
  schemas + required sets) with an explicit additions allow-list; fixture covers
  all 25 registered tools (adds browser_reload, browser_get_text).
- Navigation eviction counters are bounded (network cleared per navigation;
  console folded to a running total), fixing an unbounded per-epoch dict.

120 tests pass; new regressions for each fix; no adversarial test weakened
(the symlink test was corrected to assert the confinement invariant).
@suchintan
suchintan force-pushed the feat/mcp-gap-foundations branch from db5bcb1 to 9e9f3f8 Compare July 21, 2026 14:57
@suchintan
suchintan merged commit 8e8f14d into main Jul 21, 2026
5 of 6 checks passed
@suchintan
suchintan deleted the feat/mcp-gap-foundations branch July 21, 2026 14:57
@github-actions

Copy link
Copy Markdown

Synced to rustwright-cloud: https://github.com/Skyvern-AI/rustwright-cloud/pull/108

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants