feat(mcp): add v2 protocol and MCP Apps support - #86
Open
FreddyJD wants to merge 11 commits into
Open
Conversation
A local MCP server is an arbitrary command run with the user's own
privileges, and two of the three places Roxy reads server definitions from are
not the user: a workspace `.roxy/mcp.json` arrives with `git clone`, and the
`mcp` agent tool is driven by a model that reads web pages. Both connected
automatically, so cloning a repo and sending any message was enough to execute
attacker-chosen code.
The default stays YES. Installing a server IS the decision, and a dialog people
always approve is worse than none because it trains the click-through reflex.
What changes is that Roxy now says what happened: a sheet naming the tools that
appeared, the source they came from, and one honest line that an MCP server runs
with your access.
Only two things block:
- a trusted server id now pointing at a DIFFERENT command, which is a
substitution the user did not make, and
- the opt-in "confirm before running" posture, for shared machines.
Consent is keyed by a fingerprint of what executes (argv, cwd, env var NAMES,
never their values), so renaming an entry keeps its approval while swapping its
command revokes it. Decisions are scoped per workspace, so one repo's `db`
server never pre-approves another's.
Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com>
A `tools/call` result is structured: ordered content blocks, optional
`structuredContent`, and `_meta`. Roxy reduced all of it to
`{ ok, output: string, image? }` the moment it arrived.
That is right for one consumer, the model, and destructive for every other one.
A resource link became the prose "[resource: file://x]" with the URI no longer
addressable. A second image was dropped. `_meta` never survived at all, which is
exactly where MCP Apps identifies the view a result belongs to.
Results are now parsed into a typed model that keeps everything, and flattened
only at the boundary that needs a string. Losslessness is bounded rather than
unlimited: these connections are warm and long-lived, so every growable field is
capped at parse time and the cap is recorded in the value (`truncated`,
`omitted`, `droppedBlocks`) - a consumer must be able to tell "empty" from "too
big to keep".
Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com>
MCP split into two behavior families: `legacy` (2024-10-07 through 2025-11-25)
opens with an `initialize` handshake, `modern` (2026-07-28+) replaces it with a
`server/discover` advertisement and a per-request `_meta` envelope.
Roxy now connects with `versionNegotiation: { mode: 'auto' }`, so each server is
probed once and lands on whichever era it actually speaks. The probe cost is
paid once per server per session because the pool is warm, and it is bounded by
a short timeout so legacy servers are not slowed down.
Alongside the migration, this adds the core features the client was missing:
`resources/list`/`resources/read` (also how MCP Apps delivers a UI), OAuth for
remote servers with tokens encrypted via the OS keychain, real cancellation
through `AbortSignal`, list-change refresh, cache hints, and per-server request
timeouts.
Three bugs fixed on the way:
- The hand-rolled pagination loop was inverted under v2: `listTools()`
aggregates pages itself, and passing a cursor asks for a single raw page, so
the old loop fetched page one and then re-requested page two unaggregated.
- `structuredContent` was dropped, so a tool returning only typed output
reported as "(no output)".
- Calls arrive by tool name, so `callMcpTool` had no config in hand and always
used the global 120s default; a server configured `timeout: 800` still hung a
turn for two minutes.
Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com>
Implements SEP-1865 (stable, 2026-01-26). A tool can declare a UI in its
metadata; when the model calls it, the host reads the `ui://` resource, gets
HTML back, and renders it in the tool card. The view talks JSON-RPC to the host
over postMessage.
That HTML is arbitrary third-party code, so the whole design is about where it
runs:
- A dedicated `roxy-mcp-app://` scheme, registered before app-ready because
scheme privileges lock in at that moment.
- A double iframe. The inner frame gets `allow-scripts` WITHOUT
`allow-same-origin`, which is what makes it an opaque origin, so it cannot
read the proxy that created it.
- The renderer holds no MCP client. A view's request crosses IPC as data and
is executed in main against the ONE server that view belongs to, so OAuth
tokens and process spawning stay where the view cannot reach them.
The broker refuses cross-server calls by construction (the view sends an
unqualified tool name that main qualifies against its own session), unapproved
tool calls, CSP injection, `file://` links, unknown methods, and unbounded
heights.
Also filters app-only tools (`visibility: ["app"]`) out of the model's tool
list, which the spec makes a MUST NOT: those exist for a server's own view, and
offering them to the model invites calls the server never meant to serve.
Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com>
The plumbing the previous commits depend on: schema for the trust store,
per-row provenance and OAuth credentials; the IPC surface for consent, install
notices, sign-in and app sessions; and the Settings/MCP screens.
Two details worth calling out:
- `mcp_servers.origin` records who added a row. Without it, a server the model
added became indistinguishable from one the user typed the moment it was
persisted, and the consent gate would read it back as self-consenting.
- OAuth credentials are encrypted through the existing `secure.ts` (OS
keychain, base64 fallback) rather than stored as plain JSON. A refresh token
is a long-lived bearer credential for a third-party account.
Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com>
Adds a second mock server that speaks the 2026 era (answers `server/discover`, refuses `initialize`) alongside the existing 2025 one, so both branches of era negotiation are pinned. A suite where every server is legacy passes just as well with negotiation switched off, which is the regression worth catching. The Apps coverage deliberately includes loading the sandbox origin in a real BrowserWindow. Everything else tests the broker, and the scheme's failure mode is a silently blank frame with no error on any channel - so without a live load the tests would stay green while nothing rendered. Includes a regression test for an interop bug found by reading the official example servers: `registerAppTool` in `@modelcontextprotocol/ext-apps` emits the short `_meta.ui` key, while the spec reserves `_meta['io.modelcontextprotocol/ui']`. Reading only the qualified form renders nothing for the entire published ecosystem while looking correct against the prose. Both are now accepted, asserted against the literal shape from ext-apps/examples/quickstart/server.ts. Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com>
Every MCP App rendered as a blank frame with no error on any channel. The
proxy document loaded and announced itself, the host replied, and the reply
was silently discarded - so the proxy waited forever for HTML that had in
fact been sent.
Cause: the proxy frame is `sandbox="allow-scripts"` WITHOUT
`allow-same-origin`, which is deliberate - it is what makes the document
opaque so it cannot reach Roxy's storage. But an opaque document's origin is
not its URL, so posting to `roxy-mcp-app://view` matched nothing.
The obvious repair is also wrong: the browser rejects the literal `'null'`
outright ("Invalid target origin 'null' in a call to 'postMessage'"). No
origin string addresses an opaque frame, so `'*'` is forced.
That costs nothing here, and the reasoning is now written down rather than
left to be rediscovered. The targetOrigin was never the control doing the
work: the only reader is the frame we created and hold a handle to, which
cannot navigate away, and authenticity is enforced on the receiving side -
the host checks `event.source === frame.contentWindow` (exact window
identity), and the proxy pins the host's real origin from the first message.
The test that should have caught this loaded the proxy TOP-LEVEL, where it
reports the scheme origin the product never sees. It now loads inside a real
sandboxed iframe, asserts the origin is opaque, and drives the full round
trip (host -> proxy -> view -> host) so a dropped reply fails here instead of
as a blank frame.
Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com>
The probe hardcoded its own "*" instead of importing SANDBOX_POST_TARGET, so it validated a literal in the test rather than the value McpAppView actually posts. It would have stayed green with any broken constant - which is precisely how the targetOrigin bug shipped under a passing suite twice. The probe now injects the product constant, and an invalid value is caught and reported instead of throwing inside the page callback (an uncaught SyntaxError there killed the whole run with no attribution, which is worse than a red check). Verified by mutation: setting the constant back to 'null' fails with the browser's own message - "Invalid target origin 'null' in a call to 'postMessage'" - and restoring '*' passes. A test that cannot fail is not evidence. Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com>
The published @modelcontextprotocol/server-map rendered as a white rectangle.
Its app did execute, but Roxy launched it under the fallback CSP, truncated its
single-file bundle at the model-text limit, and sent non-spec bridge payloads.
Align the host with @modelcontextprotocol/ext-apps@1.7.5 and its basic-host
reference implementation:
- preserve content/listing resource metadata, including the Python SDK meta
alias, so _meta.ui.csp reaches the host;
- give MCP App HTML its own bounded 4 MB limit (the published map is ~325 KB)
instead of truncating it at the 200 KB model-text limit;
- serve one response-header CSP per app sandbox and load the renderer iframe
from that per-app URL;
- mirror the reference CSP directives, including declared domains in script,
style, image, connect, and worker sources;
- use allow-same-origin only on the dedicated roxy-mcp-app:// sandbox, never on
the Roxy renderer origin, and use document.write rather than srcdoc as the
reference host requires for Cesium;
- send the official ui/initialize hostContext shape, the
{ arguments: input } tool-input envelope, and a standard CallToolResult with
result-level _meta;
- stop advertising ui/message until it is actually wired to the composer;
- assign the inner frame before document.write so a synchronous ui/initialize
cannot race and disappear.
Verified against the published @modelcontextprotocol/server-map@1.7.5 package
through Roxy's real v2 client, broker, custom scheme, sandbox, and hidden
BrowserWindow. Cesium logged successful CDN load and initialization, received
the requested Namib bounds and viewUUID, loaded OpenStreetMap tiles, and logged
"Initial rendering complete".
Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com>
MCP Apps validates hostContext.styles.variables against a closed set of protocol tokens. Roxy sent private --mcp-ui-* keys, causing ui/initialize to fail and every later app request to throw Not connected. Translate the live Roxy palette to official MCP style keys, detect light mode from the actual data-appearance attribute, publish the snapshot before app launch, and sanitize IPC input so stale renderers cannot break initialization. Verified with the published @modelcontextprotocol/server-threejs@1.7.5 and @modelcontextprotocol/server-map@1.7.5 apps through Roxy's real broker and Chromium sandbox. Both initialized and successfully sent ui/update-model-context without connection/schema errors. Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com>
MCP Apps run on a custom scheme, so Chromium sends no HTTPS referrer. Its generic Electron user agent is also disallowed by OpenStreetMap's native-client tile policy, causing the official map app to receive an Access blocked PNG with status 200 instead of map tiles. Identify only requests to tile.openstreetmap.org with a stable, contactable Roxy user agent. The app still must declare the domain in its CSP, and all other MCP App traffic remains untouched. Verified in Electron with an empty referrer: the response changed from the 6987-byte no-cache policy image to the 6927-byte cacheable map tile. Co-authored-by: Roxy <299891354+roxy-commits@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
@modelcontextprotocol/server-map@1.7.5and@modelcontextprotocol/server-threejs@1.7.5apps, including large single-file bundles, official bridge payloads, strict host-style schemas, Cesium CDN assets, workers, and OpenStreetMap native-client identificationSecurity
roxy-mcp-app://origin, isolated from Roxy's renderer, preload, storage, and authenticated MCP clientshttps://tile.openstreetmap.org/*and does not grant CSP access or alter other app trafficCompatibility fixes
_metaand the Python SDKmetaalias, withresources/listmetadata as fallbackui/initialize, tool-input, tool-result, display-mode, and teardown shapesdocument.writeunder a per-app response-header CSP, matching the official basic hostVerification
npm run typechecknpm run smoke:shared(1205checks)npm run smoke:app(740checks)npm run smoke:i18n(17checks)npm run smoke:storenpm run build@modelcontextprotocol/server-threejs@1.7.5app initialized and successfully sentui/update-model-contextthrough Roxy's broker@modelcontextprotocol/server-map@1.7.5app loaded Cesium, initialized WebGL, received tool input/result metadata, and sentui/update-model-context6987-byteno-cachepolicy-block image to the6927-byte cacheable map tile