Skip to content

feat(mcp): add v2 protocol and MCP Apps support - #86

Open
FreddyJD wants to merge 11 commits into
mainfrom
fred/assess-mcp-ui-support
Open

feat(mcp): add v2 protocol and MCP Apps support#86
FreddyJD wants to merge 11 commits into
mainfrom
fred/assess-mcp-ui-support

Conversation

@FreddyJD

Copy link
Copy Markdown
Collaborator

Summary

  • add the MCP v2 client while retaining automatic compatibility with legacy MCP protocol-era servers
  • preserve structured MCP tool results internally instead of flattening content, metadata, resources, and binary blocks prematurely
  • add MCP Apps support with a server-pinned broker, approval-gated app tool calls, resource-declared CSP/permissions, and a double-iframe custom-scheme sandbox
  • add MCP server persistence, OAuth/auth handling, consent and trust controls, install/disclosure UI, IPC wiring, and localized settings surfaces
  • support the published @modelcontextprotocol/server-map@1.7.5 and @modelcontextprotocol/server-threejs@1.7.5 apps, including large single-file bundles, official bridge payloads, strict host-style schemas, Cesium CDN assets, workers, and OpenStreetMap native-client identification

Security

  • app sessions are pinned to the originating MCP server and cannot call tools on another server
  • app-initiated tool calls remain approval-gated
  • external network/resource access is derived only from declared app metadata and enforced by response-header CSP
  • unknown bridge methods, unsafe links, malformed metadata, incomplete bundles, and undeclared origins are rejected
  • app HTML runs on the dedicated roxy-mcp-app:// origin, isolated from Roxy's renderer, preload, storage, and authenticated MCP clients
  • OSM request identification is scoped to https://tile.openstreetmap.org/* and does not grant CSP access or alter other app traffic

Compatibility fixes

  • preserve resource _meta and the Python SDK meta alias, with resources/list metadata as fallback
  • use a separate bounded 4 MB app-HTML limit instead of the 200 KB model-text limit
  • send the official ui/initialize, tool-input, tool-result, display-mode, and teardown shapes
  • publish only official MCP host-style tokens and sanitize stale/unknown IPC keys so cosmetic data cannot fail initialization
  • load apps via document.write under a per-app response-header CSP, matching the official basic host

Verification

  • npm run typecheck
  • npm run smoke:shared (1205 checks)
  • npm run smoke:app (740 checks)
  • npm run smoke:i18n (17 checks)
  • npm run smoke:store
  • npm run build
  • real @modelcontextprotocol/server-threejs@1.7.5 app initialized and successfully sent ui/update-model-context through Roxy's broker
  • real @modelcontextprotocol/server-map@1.7.5 app loaded Cesium, initialized WebGL, received tool input/result metadata, and sent ui/update-model-context
  • real Electron OSM request changed from the 6987-byte no-cache policy-block image to the 6927-byte cacheable map tile

FreddyJD and others added 11 commits August 29, 2026 20:17
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>
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.

1 participant