Skip to content

Serve the run report UI to MCP clients as an MCP App - #210

Merged
wasimxyz merged 6 commits into
stagingfrom
mcp-apps-run-report
Aug 30, 2026
Merged

Serve the run report UI to MCP clients as an MCP App#210
wasimxyz merged 6 commits into
stagingfrom
mcp-apps-run-report

Conversation

@wasimxyz

@wasimxyz wasimxyz commented Aug 28, 2026

Copy link
Copy Markdown
Member

Summary

When someone asks a chat client for a Data Hub run report today, the client gets JSON back and describes it in words. After this change, a client that supports MCP Apps shows the same interactive report the Data Hub website shows: the image carousel, the plate map, the Aunty plate with its well dialog.

MCP Apps (SEP-1865) is an extension to the Model Context Protocol. The server publishes an HTML page at an address beginning with ui://, a tool result points at that page, and the client runs it in a locked-down iframe on a hostname separate from its own. Throughout this description, host means the chat client doing that work and View means the HTML page.

The same React components render the website and the View. The website feeds them over the REST API; the View feeds them over MCP tool calls.

How a report reaches the screen

  • The model calls get_run_report for an instrument and a run.
  • The host reads the resource pointer on that tool and fetches ui://data-hub/run-report.
  • The server returns the HTML plus a list of the origins the page is allowed to load files from.
  • The host renders the page in a sandboxed iframe and hands it the get_run_report result.
  • The page reads the instrument type out of that result and picks a renderer.
  • The page asks the host to call report_view_items or report_view_file_url for anything else it needs.
  • Those tools return short-lived S3 links, and the page loads the bytes from S3 itself.

No file contents pass through the web server. The server checks permissions and signs a link; S3 serves the bytes. This is what the website has always done through the /api/v1/files/:id/download redirect.

Server changes

  • ui://data-hub/run-report is a new resource. Its body is one self-contained HTML file served with the media type text/html;profile=mcp-app.
  • get_run_report now carries a pointer to that resource, so a host knows to render the page.
  • Two new read-only tools feed the page. report_view_items returns one window of report items with fresh download links. report_view_file_url returns a link for a single file, found by numeric id or by filename suffix. Both are marked visible to the page rather than the model, and both run the same permission checks the REST API runs.
  • report_view_file_url re-checks that the file belongs to the run before signing anything, because the id lookup is not run-scoped on its own.
  • Tool count goes from 32 to 34. Three tests pin that number so a change shows up in review.
  • A deploy that leaves S3_RAW_DATA_BUCKET or S3_PROCESSED_BUCKET unset now logs a warning naming the variable, once per process. Without that warning the only symptom is blank images in someone else's chat client. It does not throw, because mcp-handler rebuilds the server on every request and a throw would fail every tool rather than just the View.
  • /mcp/v1 now answers preflight requests and sends Access-Control-Allow-Origin: *, because browser-based hosts reach it from a different origin. It authenticates with a bearer token only and never sets Access-Control-Allow-Credentials, so browsers will not attach a session cookie.

Shared component changes

The seven instrument renderers used to call fetch("/api/v1/…") directly, which only works inside Next.js. They now receive a data source through React context, and each surface supplies its own. web/lib/runs/view-data-source.ts defines the contract; there is a REST implementation for the website and an MCP implementation for the View.

Requests for a window of report items now carry a cancellation signal. The search box in the item seeker fires on a 300 ms debounce and the paging buttons can be held down, so without one every superseded request ran to completion. The REST source passes the signal to fetch; the View passes it to the tool call so the host can drop the request in flight.

Two changes are visible on the website, not only in the View:

  • Plate maps always span the full column now. Before, a plate of 12 columns or fewer rendered at three-quarters width. Labels also scale with the plate's own width instead of using fixed sizes.
  • RunReportSection and RunVideoPlayer became client components. The REST data source returns download paths synchronously, so server-rendered HTML still contains the src and there is no loading flash.

Build

The View is a Vite and Tailwind app under web/mcp-apps/. npm run build runs mcp-apps:build first, then next build. vite-plugin-singlefile inlines all JavaScript and CSS, so the output is one file that makes no external requests: 1,309,067 bytes, or 353,014 bytes gzipped. Recharts and the shadcn component tree account for most of that. Hosts may cache the page.

The built file is gitignored. In development a missing build falls back to a placeholder page and logs a warning. In production it throws.

S3 bucket CORS is now open to any origin

Both data buckets go from three named origins to * in infra/template.yaml. A sandboxed iframe's origin is chosen by the host and can be null, so no fixed list can match it.

The objects stay private and are only reachable with a signed link. What changes is that a web page holding a signed link can now read the file's contents with fetch, where before it could only start a download. That has an effect only if a signed link leaks.

Testing

Checked in a browser against a local MCP Apps host:

  • Gel doc: the image carousel pages, and images load.
  • Plate reader: the plate map renders and paging works.
  • Aunty: the plate report renders, and opening a well shows the dialog and chart.

Not checked, because no local fixture data exists: the microscopy carousel, Raman spectra, qPCR, and a PDF with real page content.

Automated: make check-all passes, and 339 unit and MCP protocol tests pass.

Open item before merge

web/lib/mcp/run-report-html.ts reads the built page from disk at runtime, using a path assembled from process.cwd(). Nothing in web/next.config.mjs tells Next.js to include that file in the deployed function. Next's file tracer can sometimes work out this pattern on its own, so it may already work.

If it does not, the page is missing in production, resources/read throws, and only a host that supports MCP Apps would ever notice. Confirm on a preview deploy. If the file is missing, add:

outputFileTracingIncludes: {
  "/mcp/v1": ["./mcp-apps/dist/**"],
},

Known limits

  • App-only tools stay in tools/list for every client. Hiding them is the host's job under the spec, and mcp-handler builds a fresh server per request and registers tools before initialize, so this server cannot vary its list by client.
  • Signed links expire after 15 minutes. The View treats a cached link as stale after 12 minutes and fetches a new one on the next render. Nothing runs on a timer, so a video that a user first plays more than 15 minutes after load will request an expired link and fail without retrying.
  • A file whose link cannot be resolved shows "Loading…" indefinitely, because the resolver sets its state to null on failure and React skips the re-render that would retry.
  • Production hosts need a second hostname for the sandbox iframe. That is configured on the host side.

Docs

developer-docs/mcp-apps.md is new and covers the design, the data flow, the content security policy, and local development. getting-started.md and first-time-deployment.md gain a note that S3_PROCESSED_BUCKET must be set on a real deploy, since the policy has to name that origin even though download links never read the variable.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
data-hub Ready Ready Preview Aug 30, 2026 4:32am

Request Review

@wasimxyz wasimxyz self-assigned this Aug 28, 2026
@wasimxyz wasimxyz changed the title Serve the run report as an MCP App so hosts can reuse the web UI Serve the run report UI to MCP clients as an MCP App Aug 29, 2026
The origins came from presigning a throwaway key per bucket. That is async,
so `registerResource` snapshotted a possibly-empty cache for `resources/list`
while `resources/read` awaited the real list, and a cold instance advertised
no origins on its first listing. `s3BucketOrigin` now composes the origin
beside the `S3Client`, so one synchronous `runReportUiMeta` serves both paths
and they cannot disagree. It also skips the S3 hosts under `LOCAL_S3_MIRROR`,
where the mirror already serves bytes from the app's own origin.

Expands `developer-docs/mcp-apps.md` into a full implementation reference:
host render flow, the three app-only tools, the shared data source, the
build, and known limitations.

Co-authored-by: Cursor <cursoragent@cursor.com>
`report_view_table` and `report_view_artifact` streamed CSVs and JSON
artifacts into the function, parsed them, and returned rows. The web app has
always read the same files straight from S3 through the download redirect, so
one `report_view_file_url` now replaces both and returns a signed URL that the
view fetches and parses in the iframe. That removes the 50,000-row scan cap,
the `truncated` flag three of four callers discarded, and the double
serialization into `structuredContent` and pretty-printed text. Resolving an
uncached file id drops from four tool calls to one. Tools go from 35 to 34.

The content security policy listed only the raw and archives buckets, but
processed artifacts sit in their own bucket and are most of what the report
renders, so images, video, and CSVs would have been blocked in production. It
now lists raw plus processed through a new `S3_PROCESSED_BUCKET`, drops
archives, and repeats those origins in `connectDomains`. Both buckets move to
a `*` GET CORS rule because the sandbox origin is chosen by the host and
cannot be allowlisted ahead of time.

Also trims the comment blocks in `template.yaml` to two lines each.

Co-authored-by: Cursor <cursoragent@cursor.com>
…plate maps.

Co-authored-by: Cursor <cursoragent@cursor.com>
`fetchReportItems` now takes an optional `AbortSignal`. The controllers in
`useReportItems` were only gating `setState`, so a debounced search or a
held-down paging button left every superseded request running to completion.
The REST source hands the signal to `fetch`; the View hands it to
`callServerTool`, which tells the host to drop the in-flight `tools/call`.
The post-resolve `aborted` checks stay, because the signal is optional and an
implementation may ignore it.

`clearSearch` built a controller that nothing ever aborted, so its guards could
never fire and unmounting mid-request still wrote to dead state. It now shares
the controller `extend` uses: both load one window for the current query, so
the later action cancels the earlier one.

An unset `S3_RAW_DATA_BUCKET` or `S3_PROCESSED_BUCKET` silently produced a
content security policy that blocks that bucket's files, and the only symptom
was blank images in someone else's chat client. It now warns once per process,
naming the variable. It deliberately does not throw: `mcp-handler` rebuilds the
server on every request, so throwing would fail every tool rather than just the
run report View.

Also folds in comment edits across the View, CSP, and infra files that were
already sitting in the working tree.

Co-authored-by: Cursor <cursoragent@cursor.com>
@wasimxyz wasimxyz changed the title Serve the run report UI to MCP clients as an MCP App Serve the run report as an MCP App so chat clients render it instead of raw JSON Aug 30, 2026
@wasimxyz wasimxyz changed the title Serve the run report as an MCP App so chat clients render it instead of raw JSON Serve the run report UI to MCP clients as an MCP App Aug 30, 2026
@wasimxyz
wasimxyz merged commit d4bd562 into staging Aug 30, 2026
5 checks passed
@wasimxyz
wasimxyz deleted the mcp-apps-run-report branch August 30, 2026 04:40
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