8000 MCP server face: let AI agents drive the real browser by adeelahmad · Pull Request #10 · adeelahmad/Tether · GitHub
[go: up one dir, main page]

Skip to content

MCP server face: let AI agents drive the real browser - #10

Merged
adeelahmad merged 15 commits into
mainfrom
claude/mcp-server-1ka1ap
Oct 11, 2026
Merged

adeelahmad merged 15 commits into
mainfrom
claude/mcp-server-1ka1ap

Conversation

@adeelahmad
Copy link
Copy Markdown
Owner

Requested by Adeel · project thread

What

Before: /mcp answered 501, and agents could only reach Tether through the proxy or WebDriver.

After: /mcp is a working Model Context Protocol server. claude mcp add --transport http tether http://127.0.0.1:8787/mcp gives an agent 16 tools to work in your real, logged-in Chrome: tabs (list/new/select/close), navigate, back/forward/reload, snapshot, click, type, press key, select option, scroll, wait for, eval, screenshot, and an in-tab fetch that uses your cookies. Claude Desktop and other stdio clients connect with tether mcp, a bridge that also works across a tailnet.

  • Transport: Streamable HTTP. Supports MCP 2025-06-18, 2025-03-26 and 2024-11-05. Sessions use Mcp-Session-Id, protocol versions are checked, batches work, and DELETE ends a session.
  • Snapshot: returns the title, URL, scroll position, visible interactive elements and page text. Each element has a ref like [e42] button "Sign in". Refs are Chrome backend node IDs (the same scheme as WebDriver). A ref from a page that has since changed fails as stale_element.
  • Tabs per agent: each MCP session has one current tab and remembers the tabs it opened. When the session ends (disconnect, DELETE, or idle past --idle-timeout), only the tabs it opened are closed. Tabs it merely selected, such as the user's own, stay open.
  • Errors: failures come back as isError results with stable codes (tab_gone, forbidden, stale_element, element_not_interactable, …) and a hint the model can act on.
  • Switch: --mcp / TETHER_MCP (default on).

Bug fix: a screenshot of an idle background tab used to hang until the request timed out, because Chrome doesn't paint hidden tabs. This affected WebDriver too. cdp.Tab.Screenshot now tries for 2s, then brings the tab to the front and tries again, which is what ChromeDriver does.

Why

Adeel wants all the stubbed faces in the first release. MCP is the face the README pitch leads with ("give your AI agent your real, logged-in browser").

How

The MCP face stays thin. Every tool is a few calls into the core: Sessions, Core.Navigate/Eval/Do, core.Policy, and cdp.Tab. Nothing in internal/proxy changed.

How tested

  • make check equivalent: gofmt, go vet, golangci-lint v2.5.0 (0 issues), go test -race ./.... internal/mcp coverage is 86%.
  • make test-integration against real headless Chromium. The new TestMCP logs in, snapshots, types and submits a form, uses eval and wait_for, goes back, clicks, presses keys, scrolls, fetches with cookies, takes viewport/element/full-page screenshots, lists tabs, and checks that ending the session closes its tab. The existing proxy and WebDriver tests also pass.
  • Manual end-to-end: built the binary, ran tether serve --launch --headless --token …, and drove it through tether mcp (navigate, snapshot, type, click, eval, screenshot, ping).
  • Docs: new docs/MCP.md (client setup, tools, limits, security). Also updated the README, ARCHITECTURE, SECURITY threat model, CHANGELOG and CONTRIBUTING.

Security

  • No new way to reach the browser without auth, and nothing new is logged that could hold a cookie or token.
    • /mcp sits behind the same token auth and loopback Host/Origin guard as every other face. A test covers this through server.New.
    • --allow-domains is enforced on navigate and fetch. It is also checked against the page's current URL before every page tool, so an agent that clicks its way off the allowlist can't read or act there. If the URL can't be parsed, the check refuses.
    • Password field values are never returned, Set-Cookie is never relayed, and tool arguments are never logged.
    • Prompt injection is called out in the server instructions, docs/MCP.md and the threat model.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HpbHKDrhpTtF3U8jAhD61N


Generated by Claude Code

Extend the single global --allow-domains list into layered policies:

- internal/policy: domain rules (allow/deny, *.sub, *), principals and a
  JSON --policy-file defining named tokens with their own rules, an admin
  flag and a scripts permission. --deny-domains adds a global denylist.
- Auth attaches the token's principal to the request context; --token is
  the admin "default" principal.
- Tab ownership: non-admin tokens only see their own tabs, get their own
  per-site default tabs, and cannot adopt existing tabs.
- core.RulesPolicy checks global ∩ requester ∩ owner ∩ session rules,
  re-checks the final URL, and guards restricted tabs in the browser via
  the DevTools Fetch domain so redirects, clicks and page script obey the
  same rules.
- API: session rules on tab create, GET/PUT /tabs/{id}/policy, /whoami.
  WebDriver: tether:options.policy, ownership on every command, and
  Execute Script gated by the scripts permission.
- Docs: docs/POLICIES.md, SECURITY.md, ARCHITECTURE.md, README, example
  policy file. Integration test drives a real Chrome as a restricted token.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UfsJJgC4Jp8VqckdcChH4W
Replace the /mcp stub with a real Model Context Protocol server on the
shared session core:

- Streamable HTTP transport (MCP 2025-06-18, 2025-03-26, 2024-11-05):
  Mcp-Session-Id sessions, protocol-version checks, batches, 202 for
  notifications, DELETE to end a session. Inherits token auth, the
  loopback guard and --allow-domains from the server.
- 16 browser tools: tabs/new/select/close, navigate, history, snapshot
  (interactive elements with stable backend-node refs plus page text),
  click, type, press_key, select_option, scroll, wait_for, eval,
  screenshot and in-tab fetch with the user's cookies. Tool annotations,
  isError results with stable codes, policy checked against the page's
  current URL, password values and Set-Cookie never returned, tool
  arguments never logged.
- Each MCP session tracks a current tab and the tabs it opened; ending
  the session (or idling past --idle-timeout) closes only those.
- `tether mcp`: stdio bridge for Claude Desktop and other stdio clients,
  local or across a tailnet, with ordered forwarding.
- --mcp flag (default on) to turn the face off.

Also fix screenshots of idle background tabs hanging forever (Chrome
does not composite hidden tabs): probe briefly, then bring the tab to
the front and retry, as ChromeDriver does. Affects WebDriver too.

Docs: docs/MCP.md, README, ARCHITECTURE, SECURITY threat model,
CHANGELOG. Tests: unit tests with the cdp fakes and a real headless
Chrome integration test driving every major tool.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HpbHKDrhpTtF3U8jAhD61N
@adeelahmad adeelahmad self-assigned this Oct 11, 2026
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HpbHKDrhpTtF3U8jAhD61N
Comment thread internal/mcp/mcp.go
8000
lastUsed: s.now(),
}
s.admit(sess)
s.log.Info("mcp session started", "client", sess.client, "protocol", proto)
Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

This alert is a false positive. The only client-supplied value in this log line is clientInfo.name. At line 297 it passes through logx.Clean, which strips \r and \n and is the same sanitiser the session package uses for tab IDs. CodeQL can't see across that function call, so it doesn't count the value as sanitised. The other value, proto, can only be one of the server's own entries in supportedVersions. No log entries can be forged this way, so I'm leaving the code as it is.


Generated by Claude Code

claude added 12 commits October 11, 2026 03:39
…rint

Close the remaining WebDriver gaps on top of new cdp.Tab primitives:

- Switch To Frame / Parent Frame for same- and cross-site (out-of-process)
  frames at any depth, via cdp.Tab.InFrame and iframe auto-attach.
- Get Element Shadow Root and Find From Shadow Root, including closed roots;
  shadow roots round-trip through scripts.
- Alert commands and unhandledPromptBehavior; calls blocked by a dialog
  fail fast instead of hanging.
- Multiple windows: New Window, popups joined automatically, switching,
  closing; the session ends with its last window.
- Window rect, maximise, minimise and fullscreen, applied only to windows
  that hold nothing but Tether tabs.
- File inputs via Element Send Keys plus Selenium's /se/file upload.
- Print Page.
- Focus emulation on attached tabs so input reaches background tabs with
  cross-site iframes promptly.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhaxD4mv92X8w5zV8j34MD
POST/DELETE /session/{id}/actions dispatch key, mouse, pen, touch and
wheel input as real CDP events, tick by tick, with element/pointer/viewport
origins, animated moves and scrolls, click counting, US-layout Shift, and
input state that persists until Release Actions.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EnJnokVdV3oFBMpLc45zcJ
…t click

Chrome routes input into frames asynchronously, and on some versions a
real click into a cross-site frame of a background tab never arrives.
Arm a one-shot listener before clicking inside a frame, wait briefly for
it, and click from script if the real click did not land.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhaxD4mv92X8w5zV8j34MD
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhaxD4mv92X8w5zV8j34MD
Attaching to a tab that is closing can fail after Target.getTargetInfo
succeeds (Page.enable: session not found). Map that to ErrNoSuchTab so
faces answer 410/no such window instead of 500.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AhaxD4mv92X8w5zV8j34MD
Resolves conflicts with #7 (console logs): Tab gets both Guard and Logs,
the API keeps the policy routes and adds /tabs/{id}/logs, and docs list
both features. Also makes TestConsoleLogs tolerate a late favicon 404
between the two WebDriver log reads (it asserted the second read was
empty; it should assert no entry repeats).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9CEjRcHsJmgWdtz6ZTKsk
8000
Tab gains Mouse/Touch alongside Guard and Logs; the WebDriver session
keeps the log cursor and the Actions input state; docs list everything.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9CEjRcHsJmgWdtz6ZTKsk
# Conflicts:
#	README.md
#	internal/cdp/cdptest/cdptest.go
Conflict resolution plus one fix the combination needs: popups and new
windows now belong to the session that opened them. A popup is attached
as the session's own tab (same owner, same session rules, guarded), so a
non-admin token can switch to its popups, and a restricted session can
no longer leave its allowlist through window.open or New Window. A popup
that loaded a forbidden page before Tether attached is closed and the
switch fails.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9CEjRcHsJmgWdtz6ZTKsk
# Conflicts:
#	CHANGELOG.md
#	docs/WEBDRIVER.md
#	internal/cdp/cdptest/cdptest.go
#	internal/webdriver/errors.go
#	internal/webdriver/webdriver.go
#	test/integration/integration_test.go
Conflict resolution: the fake tab serves MCP's OnCall/Props and the
WebDriver wrappers; app wiring keeps per-token credentials and the
optional MCP face; docs describe MCP alongside per-token policies.
TestNavigateHonoursPolicy now simulates drift with SetURL, since the
browser-side guard (from policies) refuses the navigation it used.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01X9CEjRcHsJmgWdtz6ZTKsk
# Conflicts:
#	README.md
#	internal/cdp/cdptest/cdptest.go
#	test/integration/integration_test.go
@adeelahmad
adeelahmad merged commit 6dbb367 into main Oct 11, 2026
13 checks passed
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.

3 participants

0