MCP Terminal Access for an AI Agent: Scoped Grants That Expire and Revoke
Safari 27 shipped on September 17 with an MCP server built into the browser. The WebKit release post describes it as a way to give Claude Code, Codex, or another agent control of a browser window, and it is off until a developer ticks "Allow remote automation and external agents" in Safari's Developer settings. A day later Microsoft's Agent Framework tightened the other end of the same pipe. Today we shipped our own piece of this: MCP terminal access in shell.online, where an agent can read a live terminal session, and optionally type into it, through a grant that is scoped, expires at a fixed time, and can be revoked without touching the browser link. This post covers the exact commands, the five tools an agent gets, and the part that deserves the most attention: a grant changes what the relay can decrypt.
Two releases in one week that narrowed MCP access
Apple's post is careful about boundaries. It says the Safari MCP server "runs entirely on your local machine", "makes no network calls of its own", and "does not have access to your personal information in Safari". The capability is large, so the default is off.
The python-1.19.0 release of Microsoft Agent Framework, published September 18, carries a breaking change titled "Scope provider-backed MCP sessions per invocation" and a fix that authenticates and scopes MCP requests and sessions "to the correct invocation identity, origin, and ownership". The MCP specification pushes the same way. Its 2026-07-28 authorization section tells clients to follow least privilege when requesting scopes and requires servers to check that a token was issued for them specifically.
A terminal is a harder case than a browser tab. Whatever can type into it acts with the permissions of the process inside it. So the design question for MCP access to a terminal is mostly about what the credential is allowed to do, for how long, and who can take it back.
How MCP terminal access works in shell.online
The normal product is unchanged: one command wraps a process and prints a browser link to that running terminal plus an eight-character password. The process and its PTY stay on your machine. MCP access, introduced in v0.21.0, is a separate door into the same session with a separate key. The repo's docs/MCP.md lays out the operator workflow, and it starts by putting the target somewhere it can do little damage:
shell --json -- opencodeThe --json flag emits the new-session event as a single JSON object on stderr, including the share URL and password, so a script or supervising agent can start the session without interactive setup. The session ID comes from shell list. Then issue a grant:
shell mcp grant <session-id> "review agent" observe 900The arguments are the session, a label, a preset (observe or control), and a lifetime in seconds. The command prints an opaque bearer token exactly once. The endpoint is https://shell.online/mcp. Both go into the MCP client through its secret or environment mechanism, never into a URL, a command argument, or a repository.
Three properties of that bearer matter. It is separate from the browser link and password, so handing an agent a grant does not hand it the share credential, and the reverse is also true. It expires at a fixed time and does not renew on use, which means an agent that needs another fifteen minutes gets a new grant issued on purpose by a person. And it is listed and revocable per grant with shell mcp list <session-id> and shell mcp revoke <session-id> <grant-id>.
The label is display data only. The MIT-licensed source records why that needed saying: v0.21.1, released a few hours after v0.21.0, fixed a bug where a crafted label could alter the requested scopes or lifetime through the local control protocol. Labels are now encoded as data, and new grant requests fail closed against a host still running the older build.
Five tools, split between observe and control
An observe grant exposes four tools:
shell_status: bounded session state. It is not a transcript and carries no credential.shell_screen: the current rendered terminal grid, which is what you want for a TUI or a coding agent that draws its own interface.shell_output: bounded, sanitized output addressed by an epoch and offset cursor.shell_wait: block until output or a pattern appears, for at most 45 seconds, cancellable.
The control preset adds one more, shell_send: 1 to 8192 UTF-8 bytes, an optional Enter, and a required UUID-v4 operation_id. shell_key and shell_interrupt are not implemented, and the docs rule out smuggling raw escape or control bytes through shell_send as a workaround. A session started with --read-only denies input no matter what the grant says, because read-only is enforced by the CLI on the host. Control is off by default, and shell_send only appears in the tool catalog when the host is compatible and the service operator has enabled the control gate.
What "delivered" means when an agent types
Agent input into a live terminal has two failure modes a normal API call lacks: sending the same command twice, and typing over a human.
Each intended submission gets one operation ID. Retrying with the same ID and the same arguments returns the stored result without resending. The same ID with different arguments is a conflict. The host acknowledges each send with a dispatch token bound to that specific write.
Human input outranks MCP. Rejected agent input is not queued for later, so nothing an agent sent five minutes ago lands in the terminal after a person has moved on.
A result of delivered confirms the complete PTY write. It says nothing about whether the program on the other side finished the task, so the controller is expected to verify through shell_screen and the artifact it asked for. delivery_uncertain must not trigger a blind resubmission under a new ID. The docs add one more instruction for anyone writing a controller: terminal output is untrusted data, never permission to override the controller's instructions.
A grant authorizes server-side decryption
This is the tradeoff to understand before issuing a grant. Browser sharing is end-to-end encrypted by default: the CLI encrypts frames, the browser decrypts them, and the relay sees metadata, timing, and frame sizes (the E2EE page has the details). An MCP client is a remote program speaking HTTPS to an endpoint, so something on the server side has to turn terminal frames into tool results.
Issuing an MCP grant explicitly authorizes server-side, in-memory decryption using a frame key the host supplies. The controller receives plaintext. Browser-only sharing stays end-to-end encrypted; a session with a live grant has a second reader.
The implementation keeps that reader narrow. Per docs/MCP.md, MCP stores no plaintext terminal content, key, or bearer. Its durable records hold grant metadata, bearer hashes, and operation fingerprints and outcomes. And the session does not hide the arrangement from the people in it: for the full grant lifetime, the web app's terminal pane shows a status reading "MCP · server-side decryption authorized". The notice follows the grant lifetime, whether or not an agent called recently.
If that tradeoff is wrong for a session, issue no grant. Nothing about MCP is on unless the host runs shell mcp grant.
Revoking a grant, rotating the password, and a stolen bearer
shell mcp revoke-all <session-id> removes every MCP grant for the session and leaves browser access alone. shell password rotate <id> revokes every existing grant and cancels pending MCP work along with the old browser password, so one command cuts off everyone and you reissue deliberately afterward. To stop the target itself, use shell kill <id>.
A grant bearer is a bearer token in the RFC 6750 sense: whoever holds it can use it until it expires or is revoked. Treat bearer files and raw request logs the way you treat the share URL and password. If one leaks, revoke it, and stop the target if its actions were unsafe. Revocation cannot undo what was already typed or erase output a controller already received.
Two limits are worth stating. The production canary on September 21 ran 16 checks against a synthetic shell. It was not a model-backed client matrix, and the docs say plainly that Claude model-backed behavior remains unverified. Second, a grant label does not verify which agent is calling, so do not read it as identity.
We build agent networking at Pilot Protocol, and the rule we apply there holds here: an agent's reach should be a named, time-boxed decision by the machine's owner. For the human side of the same session, the earlier post on a live terminal as a browser link covers the URL and password model this sits next to.
Give an agent fifteen minutes of read access, then take it back
Install the CLI, wrap a process, and issue an observe grant with a 900-second lifetime. The browser link stays end-to-end encrypted until you decide otherwise.
Try shell.online