Connectors (MCP) and the headless browser (Playwright)
Part of the developer notes; the index is in CLAUDE.md.
- Connectors (MCP) (
client/oppx/src/mcp.rs,oppx_chat/mcp.py): tools from MCP servers, for theagentengine. They live on the client, like everything that executes: a connector is a program on the laptop or a URL, used with the user’s own credentials. The server runs none of it and never sees the definitions.-
Add, view, remove:
oppx mcp add <name> [-e KEY=VALUE] -- <command> [args], oroppx mcp add <name> --url <https://…> [-H 'Name: value'];--projectsaves it for the repository,--forcereplaces one.oppx mcp list(or justoppx mcp) shows yours and the repository’s;oppx mcp remove <name>drops one.- In the chat:
/mcplists them with tool counts and what they cost per request,/mcp tools <name>shows a connector’s tools,/mcp add …and/mcp remove …run the sameoppx mcpcommands and reconnect,/mcp reconnectretries one that failed. The welcome box and/statusname them, and/contexthas a Connectors line.
-
Example, Linear (also in
README.md): remote server, streamable HTTP, so no OAuth inoppx; pass a personal API key as a header, or letmcp-remotedo the OAuth login over stdio:export LINEAR_API_KEY=lin_api_... oppx mcp add linear --url https://mcp.linear.app/mcp -H 'Authorization: Bearer ${LINEAR_API_KEY}' # or, browser login (needs Node on the client): oppx mcp add linear -- npx -y mcp-remote https://mcp.linear.app/mcpThe single quotes matter:
${LINEAR_API_KEY}is expanded byoppxat start (see below), so the key stays out ofmcp.json. Not yet tried against Linear’s live server. -
One common format: the
mcpServersblock every MCP client reads (command/args/env, ortype: httpwithurl/headers), so a connector’s install snippet works unchanged. Yours are inmcp.jsonnext toconfig.json(0600, it may hold tokens;OPPX_MCP_CONFIGoverrides), a repository’s in.mcp.jsonat its root.${VAR}and${VAR:-default}in any value are read from the environment at start, so.mcp.jsoncan stay free of secrets. On a shared name yours wins.oppxwrites these files; the frontend only reads them. -
A repository’s connector needs consent: it is a command from a file in the repo, so cloning a repo must not be enough to run it. The chat asks once per repository and definition (the hash is kept in
~/.local/state/oppx/mcp-approved.json; a changed command asks again); until then it shows as “waiting”. Print mode can’t ask, so it stays waiting.oppx mcp add --projectcounts as consent. -
Transports: stdio (one JSON-RPC message per line; started in its own session, so Ctrl-C in the chat doesn’t reach it; its stderr goes to
~/.local/state/oppx/mcp-<name>.logand the last line is shown when it fails) and streamable HTTP (answers as JSON or as an event stream, session id kept). Not supported: the older SSE transport, OAuth (pass a token with-H), and resources, prompts and sampling. The client is our own (standard library, plus the engine’shttpxfor HTTP), not themcppackage, which the engine’s environment doesn’t have. -
Arguments are the tool’s own fields: the arguments call sees the action that chose the tool as a sentence (“I’ll call linear.save_issue now.”), and the arguments used are kept with the result, not merged into the action. Merged, they taught the model to write the whole action as the arguments (
{"enough": false, "tool": …, "arguments": {"id": "CHO-7", …}}): Linear’ssave_issueschema isn’t one SGLang’s grammar takes, so the unconstrained retry copied that shape and Linear refused it eight times. Such output is unwrapped anyway (clean_arguments: the innerargumentsobject, and our action’s keys dropped unless the tool declares them), and a call that failed is refused when made again with the same arguments, with its error, instead of running again. -
How the model calls one (model-agnostic, no tool-call parser): the tools are never sent as the OpenAI
toolsparameter. The system prompt lists each as one line (<name>.<tool>: the first sentence of its description, at most 140 characters), and the names join thetoolenum of the action schema. After the model picks one, a second call writes the arguments, constrained by that tool’s owninputSchema(response_formatjson_schema,$schemadropped) and shown the full description. If the server’s grammar refuses the schema (a 400), it is asked again unconstrained and checked here (a JSON object with every required key). A tool without arguments skips the second call. -
Asking, as for commands (
Permissions.judge_tool/answered_tool): a tool its connector marksreadOnlyHintruns without a question, like a command that only reads. Any other tool is asked once:y(this time),a(always, this tool),c(always, every tool of that connector: the rulelinear.*),N, or text that declines and is passed on as what to do instead. Rules are saved per project with the command rules (/permissions), and a deny rule (linear.save_issue, orlinear.*) wins over read-only. Print mode declines what it would ask. Plan mode and/askoffer only the read-only tools. With Linear, reading an issue and listing its statuses used to ask twice before anything happened; now only the write asks. Results are marked as untrusted reference text and cut at 12k characters. The same call with the same arguments twice in a turn is a repeat. A connector call counts as the work that was asked for: “open an issue” doesn’t get the “nothing was changed” nudge or opening. -
Context cost, and the warning: the tool list is in every request (about 50 tokens a tool in the one-line form; the schema is only sent for the tool being called).
oppx mcp addstarts the connector once (python -m oppx_chat.mcp probe, when the engine is installed) and prints its tools and tokens, with the share of the server model’s context (/v1/info, 4 s limit). One that doesn’t start is saved anyway, with the reason.- It warns when the context is 32k or less (
SMALL_CONTEXT), or when the list takes 10% of it or more (HEAVY_SHARE): “the server’s model has a small context window (32k tokens). This connector’s tool list takes about 212 tokens of every request (0.6%), and each tool result uses more. Keep only the connectors you need…”. - The chat gives the same warning after
/mcp add(with the total of all connectors), and at start only for a heavy list, so it doesn’t repeat every session.
-
Prefix cache: connectors start before the first request (in parallel, 30 s each at most), so their tools are in the system prompt from the start. A
/mcp add,removeorreconnectthat changes the list rebuilds it: one uncached request, and the chat says so. -
When a connector stops working (
mcp.diagnose,ConnectorDown; a Linear login that expired showed only as “linear.save_issue failed: …”, again and again):- Why, from its error: it needs a new login (HTTP 401/403, “unauthorized”, “token expired”; or an
mcp-remoteconnector that stops answering, since it waits silently for the login in the browser), its program isn’t installed, its program stopped, it can’t be reached, it didn’t answer in time. Matching is narrow on purpose: a tool that refuses its arguments (“the login field is required”) is not a lost login, and one slow call doesn’t take a connector down. - The steps back, per kind and per way it was added: for
mcp-remote,/mcp reconnect <name>(which waits 3 minutes for the browser login) and, if that fails, deleting~/.mcp-authand coming back withoppx -c; for a token in a header, which environment variable it is read from and theoppx mcp add … --forceline for a new one; for a missing program, what to install; otherwise the reconnect and where its log is. - In a turn (
Hub.call): a program that stopped is started again once and the call repeated, without anyone noticing. Anything else marks the connector failed and raisesConnectorDown: the user gets a notice with the numbered steps at once, the connector’s tools leave the action list, the model is told not to retry or work around it, and the reply’s facts tell it to open with what wasn’t done and to pass the steps on, ending with “once it is back, ask me to continue”. /mcpshows each connector that isn’t running with the reason and the steps; so does the line at start./mcp reconnect <name>(also/mcp auth <name>) starts that one again withLOGIN_TIMEOUT(180 s) instead of the 30 s every connector gets at start, and says to continue when it is back; plain/mcp reconnectrestarts all of them as before.- Tested: unit tests for the diagnosis of each kind, a killed connector restarted for the call, a lost login taking the connector down and one reconnect bringing it back, and the agent’s side (the notice, no second call, the steps in the reply’s facts). In the real chat with two broken connectors (one that never answers, one whose command doesn’t exist): the start line and
/mcpshowed the reasons and steps. Not yet tried: a real expired Linear login throughmcp-remote, and/mcp reconnect linearopening the browser.
- Why, from its error: it needs a new login (HTTP 401/403, “unauthorized”, “token expired”; or an
-
Tested:
scripts/test_connectors.py(16 tests, no server needed): the client against a stdio server it writes itself, and the agent loop with a scripted model. By hand against the running server (Qwen3.8-27B-INT4, 32k context), with a test stdio connector and a test HTTP one (event-stream answers, session id, bearer header, 401 without it):- the real chat in a pty: the welcome line,
/mcp,/mcp tools,/mcp remove, then “Save a note titled “Standup” with body “9am daily”…“: it pickednotes.add_note, wrote{"title", "body"}from the schema, asked, and the note was saved;/mcp addreconnected and gave the small-context warning; - five headless turns: a note with an optional enum argument, a tool without arguments, a declined call reported as not done, and plan mode offering only the read-only tools; 2–9 s for each of those turns.
- Not yet tried: published connectors (GitHub, filesystem), schemas with
anyOfor$refagainst SGLang’s grammar, and small models (Qwen2.5-Coder-14B).
- the real chat in a pty: the welcome line,
-
- Browser (Playwright) (
client/oppx/src/browser.rs,oppx_chat/browser.py; CHO-6): a headless browser the agent can drive to open pages (a local dev server or any URL), click, type, and read the page, its console errors and network requests. Like everything that executes, it runs on the client, not the server: it has to reach the user’slocalhost, and it costs no GPU (one headless Chrome, a few hundred MB of RAM while a session uses it).- What it is: Playwright’s MCP server (
@playwright/mcp, pinned tobrowser::MCP_VERSION, 0.0.83), run as a connector oppx starts itself (Hub.start, kept across/mcp reconnect; a user connector namedplaywrightis ignored). Flags:--headless --isolated(profile in memory),--image-responses omit,--codegen none,--console-level warning,--file-paths absolute, output under~/.cache/oppx/browser/<session>. Uses the system’s Google Chrome when there is one (--browser chrome), else Playwright’s Chrome for Testing in<engine>/playwright/browsers. - Install: on first use (the first “yes” in a chat runs
oppx browser install), oroppx browser install/on:npm install --prefix <engine>/playwright(about 20 MB; 0.4 s from npm’s cache here), plus about 170 MB of Chrome for Testing only without a system Chrome. Needs Node.js 18+ (nodeon PATH); without it the chat says so and the model carries on.oppx --updateupdates it only when it was installed before. - Asked once a session, off by default: the agent has a
browseraction (one line in the system prompt, only when the session can ask). Choosing it asks “Start a headless browser (Playwright) for this session?”: y, a (always:oppx browser on, saved asbrowser_autoinconfig.json), N, or text passed to the model. A no holds for the session: the action leaves the list and the model is told not to ask again. Withoppx browser onit starts with the session, before the first request, without asking. Print mode can’t ask, so it only has the browser when it is on./browsershows the state;/browser start | stop | on | off. - Once started: its tools join the connector list (21 of its 25, about 750 tokens a request; the system prompt is rebuilt once). They run without a question for the rest of the session (
Permissions.allow_for_session, never saved), exceptbrowser_evaluate(JavaScript the model wrote), which is asked every time; deny rules still win. Never offered:browser_run_code_unsafe,browser_file_upload,browser_drop(they reach the user’s files or run arbitrary code) andbrowser_take_screenshot(images are left out of results; the snapshot is the page as text). - Results: the page snapshot (an accessibility tree with refs such as
e3to click) and new console entries, which the server writes to files, are put into the result (browser.inline_files), since the model can’t open files outside the repository. - Repeats: the browser is a stateful connector (
Hub.stateful). The same click twice is two clicks (the connector repeat guard refused the second click in the first live run); a read-only call counts as a repeat only when nothing changed the page since; the same page-changing call more than 5 times in a row is refused as a loop. - Choosing it: the model decides; when the browser is offered, a message that names a URL (
localhost:5173,http…) or reads like it needs a running page (dev server, console errors, “actually shows”, blank page…) gets a one-line note that grep and read can’t see a running page (browser.hint). Measured withscripts/probe_browser.py(a small React app, 10 messages that need the page and 10–14 that don’t, the first three actions of the real loop) on split Qwen3.8-27B: without the note 15/20, with it 18/20, and after widening it 48/48 over two runs with 24 cases (no false starts on “add a footer to the home page component” and the like). - Tested:
scripts/test_connectors.py(Browser: offered, started, its tools offered and hidden ones not, a no is final, a reconnect keeps it, inlining, the hint, repeated clicks),scripts/test_permissions.py(session allow); the real server throughHub.start(0.17 s to connect, navigate 1.9 s with Chrome starting, click by ref, console and network requests); and a real chat in a pty on Qwen3.8-27B: asked, y, installed on first use into the real engine folder, opened a local page, clicked Add twice, answered “2” with the three console errors. Not yet tried: the Chrome for Testing download (this machine has Chrome), macOS, and a real dev server (Vite) instead of a static page.
- What it is: Playwright’s MCP server (