Skip to content

feat: interactive panels via the MCP Apps extension - #34

Merged
Showdown76py merged 9 commits into
mainfrom
feat/mcp-apps-panels
Jul 29, 2026
Merged

feat: interactive panels via the MCP Apps extension#34
Showdown76py merged 9 commits into
mainfrom
feat/mcp-apps-panels

Conversation

@Showdown76py

@Showdown76py Showdown76py commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Brouillon volontaire. Le serveur est prêt, mais le dashboard intégré ne rend pas encore les panneaux. Voir « Ce qui manque » en bas et #35. Cette PR remplace #33, fermée : tout son contenu est ici.

Ce que ça apporte

Trois outils qui embarquent une interface, via l'extension MCP Apps (io.modelcontextprotocol/ui). Chaque outil porte _meta.ui.resourceUri vers une ressource ui:// servie en text/html;profile=mcp-app, que le client rend dans une iframe sandboxée et avec laquelle il dialogue en JSON-RPC sur postMessage.

Outil Interface
proxmox_vm_panel Une VM ou un CT : CPU / RAM / disque en direct, start / stop / restart, champs cœurs et mémoire.
proxmox_logs_panel Syslog ou historique de tâches d'un nœud, défilable, filtres par niveau et sous-chaîne, erreurs colorées, plein écran.
cluster_overview_interactive Cartes de nœuds avec pression CPU/RAM, table de VM filtrable avec start/stop par ligne, pools de stockage.

Après chaque action, le panneau repousse l'état frais avec ui/update-model-context. Sans ça le modèle garde ce que l'outil avait renvoyé à l'ouverture : arrêter une VM depuis le panneau laissait le tour suivant croire qu'elle tournait, parce que le résultat du tools/call revient à l'iframe, pas au modèle.

Où l'hôte annonce ui/message, le panneau VM propose un bouton qui demande au modèle d'enquêter sur l'invité, et le tableau de bord un bouton « open » par ligne. Les deux sont gatés sur la capacité et masqués sinon.

Pas de migration mcp 2.0 requise

La classe Apps qui emballe tout ça vit dans mcp 2.0 et exige MCPServer, mais elle ne fait que deux choses : estampiller meta= sur l'outil et poser mime_type= sur la ressource. Les deux existent déjà sur FastMCP en 1.29, et le format de fil est identique. Les panneaux ne sont donc pas bloqués derrière le pin <2 de #32.

Structure

Les panneaux sont dans src/beaconmcp/proxmox/apps/. Ils partagent bridge.js (le client JSON-RPC) et panel.css (l'apparence), injectés au marqueur <!--mcp-runtime--> à la lecture de la ressource, pour que chaque panneau reste le document autonome unique que le format impose.

Sécurité

Aucun panneau n'a d'accès propre au cluster. Chaque bouton émet un tools/call ordinaire vers les outils existants (proxmox_vm_start, _stop, _restart, _config), donc l'approbation que le client applique à n'importe quel appel d'outil s'applique ici aussi. Un panneau est une façon plus agréable d'émettre l'appel, pas une façon de contourner la demande.

Les trois outils de panneau sont eux-mêmes en lecture seule.

Dégradation

Un client qui n'a pas négocié l'extension ignore _meta.ui et affiche la valeur de retour, qui est le même instantané sous forme de données. C'est pour ça que chaque outil renvoie l'état complet plutôt qu'un texte du genre « voir le panneau ».

Vérification

369 passed (+23 sur cette branche), ruff check clean, et les cinq assets sont bien dans la wheel — les panneaux sont lus depuis le disque à l'exécution, donc un fichier absent du paquet casserait une install pip alors que tout passe en local.

Les tests figent le format de fil : _meta.ui de chaque outil, type MIME, résolution effective de l'URI annoncée (une resourceUri qui renvoie 404 donne une iframe blanche), disparition du marqueur <!--mcp-runtime--> avec présence des deux moitiés du runtime, et la forme plate des paramètres de ui/initialize.

Le JS a été repassé au navigateur contre un harnais qui valide le handshake comme un vrai hôte. C'est ce qui manquait au premier essai : un harnais complaisant a laissé passer un ui/initialize malformé, l'hôte réel l'a rejeté en silence, et le panneau restait sur « Loading… » (corrigé en 9f9e987). Un handshake sans réponse pendant 5 s affiche désormais la raison au lieu de tourner indéfiniment.

Ce qui manque avant de merger

Le dashboard intégré, sur /app/chat, ne rend pas les panneaux — il montre le JSON. Il est à la fois client MCP et hôte, donc il faut annoncer l'extension à l'initialize de la ClientSession et implémenter le côté hôte du postMessage dans chat.js.

C'est bloqué par la migration mcp 2.0 : le champ extensions de ClientCapabilities, par lequel un client se déclare compatible, n'existe pas en 1.x.

mcp 1.29 ClientCapabilities : elicitation, experimental, roots, sampling, tasks
extensions présent ? False

Asymétrie à noter : côté serveur la migration n'était pas nécessaire, côté client elle l'est. Suivi dans #35.

Les panneaux fonctionnent déjà en production dans les clients externes compatibles, donc merger avant le support dashboard reste défendable — c'est un choix de séquencement.

proxmox_vm_panel carries _meta.ui.resourceUri pointing at a ui:// resource
served as text/html;profile=mcp-app, which an Apps-capable client renders in
a sandboxed iframe: live CPU/RAM/disk, start/stop/restart, and fields for
core count and memory.

Runs on mcp 1.x. The Apps class that wraps this lives in 2.0 and needs
MCPServer, but the two knobs it sets -- meta= on the tool, mime_type= on the
resource -- are already on FastMCP, so the panel does not wait on that
migration.

The panel holds no cluster access of its own. Its buttons issue ordinary
tools/call requests for proxmox_vm_start / _stop / _restart / _config, so the
client's approval prompt still stands in front of every action. Clients that
skipped the extension ignore _meta.ui and get the same snapshot as data,
which is why the tool returns the full state rather than a placeholder.

Verified against a harness that speaks the host side of the protocol:
handshake, initial render, power actions with refresh, config apply sending
only changed keys, tool errors surfacing without wedging the controls, and
the theme switch.
The handshake nested appCapabilities under a `capabilities` key and sent
`appInfo` as `clientInfo`. The real shape, per the ext-apps App.connect()
implementation, is flat: appInfo / appCapabilities / protocolVersion.

A rejected handshake is silent -- the host simply does not reply. So the
promise never settled, `ui/notifications/initialized` never went out, the
host never delivered `ui/notifications/tool-result`, and the panel sat on
"Loading..." with an empty frame and nothing in the console. That is what
showed up in Claude: the host reported the widget as rendered while the
iframe stayed blank.

Also surface the failure instead of hanging on it. A handshake that goes
unanswered for 5s now replaces the spinner with the reason, so the next
protocol mismatch is one glance rather than an afternoon.

The browser harness this was first tested against replied to any
ui/initialize it received, which is why the bad shape passed. It now
validates the params like a host does, and the new test pins the flat
shape against the shipped HTML -- it fails on the old file.
Three additions on top of the VM panel.

proxmox_logs_panel renders a node's syslog or task history as a scrollable
list with level and substring filters, error and warning lines coloured, and
a fullscreen request. Logs are the worst thing to read through a chat
transcript: the model summarises them and the lines you wanted are gone.

cluster_overview_interactive is cluster_overview as a browsable panel --
node cards with CPU and memory pressure, a searchable guest table with
inline start/stop, storage pools with usage bars. It reuses the aggregators'
collection helpers rather than re-querying Proxmox its own way.

Both panels, and now the VM panel, push state back with
ui/update-model-context after an action. Without it the model keeps whatever
the tool returned when the panel opened, so stopping a VM from the panel
left the next turn believing it was still running. Where the host advertises
ui/message, the VM panel offers an "ask about this guest" button and the
dashboard an "open" button per row; both are hidden when it does not.

Three panels meant three copies of the JSON-RPC bridge, so it moves to
apps/bridge.js with the shared look in apps/panel.css, spliced in at the
<!--mcp-runtime--> marker when the resource is read. Each panel still ships
as one self-contained document.

Verified in a browser against a harness that validates the handshake the way
a host does: rendering, filters, source switching, inline power actions with
reload, context updates and messages arriving with the right shapes, and the
graceful path when the host advertises neither capability.
@Showdown76py
Showdown76py marked this pull request as draft July 29, 2026 04:24
@Showdown76py

Copy link
Copy Markdown
Owner Author

Passée en brouillon : à ne pas merger tant que le client web n'est pas traité.

Le manque

Les panneaux ne s'affichent que dans un client externe qui a négocié l'extension. Le dashboard intégré de BeaconMCP, sur /app/chat, ne les rend pas — il n'y a aucune gestion de ui:// ni d'iframe dans src/beaconmcp/dashboard/. Concrètement, un utilisateur du dashboard qui appelle proxmox_vm_panel récupère le JSON de l'instantané, pas le panneau. Ça marche, mais ce n'est pas ce qu'on vend.

C'est d'autant plus gênant que le dashboard est la seule interface qu'on contrôle de bout en bout.

Ce que ça demande

Le dashboard est à la fois client MCP et hôte. Il faut les deux moitiés :

Côté client (chat.py) — annoncer l'extension à l'initialize de la ClientSession, avec text/html;profile=mcp-app dans ses mime types, sinon le serveur considère à juste titre que le client ne sait pas faire. Puis, sur un résultat d'outil portant _meta.ui.resourceUri, aller lire la ressource avec resources/read.

Côté hôte (chat.js) — rendre le document dans une iframe sandboxée et implémenter le côté hôte du protocole postMessage : répondre à ui/initialize, pousser ui/notifications/tool-result, relayer les tools/call vers la session MCP, et décider quoi faire de ui/update-model-context et ui/message du côté de la boucle Gemini.

Il faudra aussi trancher la question de l'approbation. Aujourd'hui la garde _NEEDS_CONFIRMATION s'applique aux appels décidés par le modèle. Un tools/call venu d'une iframe est déclenché par un clic humain, donc la modale est probablement redondante — mais c'est une décision explicite à prendre, pas un détail d'implémentation, et elle touche le travail de #24.

Le blocage réel

Ça dépend de la migration mcp 2.0. Vérifié :

mcp 1.29 ClientCapabilities : elicitation, experimental, roots, sampling, tasks
extensions présent ? False

Le champ extensions, par lequel un client annonce io.modelcontextprotocol/ui, n'apparaît que dans mcp_types 2.0.0, attaché à la révision 2026-07-28. En 1.x il n'existe pas, donc le dashboard ne peut pas se déclarer compatible dans les formes.

C'est exactement la migration qu'on a repoussée derrière le pin <2 de #32. Côté serveur elle n'était pas nécessaire — on l'a montré, les panneaux tournent en 1.x parce que le format de fil suffit. Côté client, elle l'est.

Donc l'ordre est : migration mcp 2.0, puis support Apps dans le dashboard, puis merge de celle-ci.

Ce qui n'est pas bloqué

#33 et cette PR restent utiles telles quelles pour les clients externes, où les panneaux fonctionnent déjà en production. Si tu préfères ne pas attendre la migration, on peut merger les deux et suivre le support dashboard à part — c'est une décision de séquencement, pas une correction à faire ici.

@Showdown76py Showdown76py changed the title feat(panels): log viewer, cluster dashboard, and model-context sync feat: interactive panels via the MCP Apps extension Jul 29, 2026
Showdown76py and others added 6 commits July 29, 2026 07:20
)

* feat(auth): passkey sign-in and a confirmation step on both login pages

Adds WebAuthn as an alternative second factor on /app/login and
/oauth/authorize. A passkey replaces the TOTP code, never the client
secret: both pages keep the client_id + client_secret step first, so a
stolen passkey is useless on its own -- and the dashboard session has to
encrypt the secret anyway to re-mint MCP bearers later.

Both pages now confirm before moving on, instead of redirecting the
instant 2FA clears:

* the validate button shimmers while the request is in flight, and
  Enter submits as soon as the sixth digit lands;
* the screen that follows shows when access expires, offers to enrol a
  passkey on this device, and waits for "Finish signing in".

On /oauth/authorize that also fixes a latent papercut: the authorization
code is minted at "Finish", not before, so its 60 s OAuth 2.1 lifetime is
no longer burned while a human reads the page. The approval is held as a
single-use in-memory ticket instead.

Both flows stay fully functional with JavaScript off -- the forms POST
and the confirmation panel renders server-side.

Storage is a new `passkeys` table (schema v5) holding a credential id, a
public key and a signature counter; challenges are in-memory and
single-use. Credentials are listed and removable from /app/tokens.
Dynamically-registered clients delegate to their owner's passkeys, the
same way they already delegate TOTP.

py_webauthn is imported lazily: without it the pages simply hide every
passkey affordance and TOTP remains the only way in. Same when the
browser has no secure context (plain-HTTP LAN deployments).

Tests drive the real ceremonies against a software ES256 authenticator
built in the test module, covering wrong origin, wrong RP ID, replayed
challenge, foreign credential, counter regression and the CSRF rotation
that signing in performs.

* fix(passkeys): say why passkeys are unavailable instead of hiding silently

When the second factor cleared, the confirmation screen simply had no
"Add a passkey" button and no explanation. Two independent gates can
switch the feature off and neither was surfaced anywhere:

* server side, the optional `webauthn` package may not be installed
  (a fresh `git pull` without `pip install -e .` is enough);
* browser side, WebAuthn is only exposed on a secure context, so a
  plain-HTTP origin has no API to call.

Now:

* the boot banner prints `Passkeys:  enabled` or `disabled - <reason>`;
* `beaconmcp doctor` gained a Passkeys section naming the fix command;
* the pages render a short hint when the *server* offers passkeys but the
  *browser* refuses them, instead of dropping the button with no trace.

Hiding the affordance from an anonymous visitor is still right -- there
is nothing they could do about it -- but the operator now has three
places to ask the question and get an answer.
…P tools (#37)

* feat(updates): tell signed-in operators about updates, and offer to apply them

BeaconMCP cuts no releases and ships no PyPI package: the canonical install
is a git clone with a venv and a systemd unit. So "is there an update?"
means "is this checkout behind the upstream default branch?", and nothing
in the server was answering that question. Operators found out by
happening to read the repo.

Adds three things.

**A notice, for signed-in operators only.** A card on any /app/* page when
the checkout is behind: how far, the recent commit subjects, a link to the
diff, and the commands to update. GET /app/api/update requires a live
session and 401s otherwise -- the exact revision a server runs is free
reconnaissance for anyone who hasn't authenticated, and the card is only
ever rendered to someone signed in. Dismissing it hides that revision
until a newer one lands.

**Instructions that match the install**, rather than assuming everyone ran
deploy/install.sh. A git checkout gets its own root and its real venv pip
path, plus a systemctl line only when a unit file actually exists; a
container gets docker compose; a pip distribution gets the git+https URL.

**Two MCP tools.** beaconmcp_check_update is read-only. beaconmcp_self_update
applies: pull --ff-only, reinstall dependencies, validate the config, then
restart. It requires confirm=True, refuses a dirty checkout so local edits
are never discarded, and refuses a non-git install.

The config validation is a hard gate, not a warning, and it is what makes
this safe to run unattended: it shells out to `beaconmcp validate-config`
so the *new* code parses the operator's *actual* config. If a setting was
renamed or a new one is now required, the checkout is reset to where it
started, dependencies are restored, and nothing is restarted -- an update
that bricks the server is worse than no update.

The check also diffs the incoming .env.example / beaconmcp.yaml.example
against the operator's real files (not the local examples, and honouring
variables already exported), so the notice can say "this update wants a
variable you haven't set" *before* it is applied.

The dashboard's "Update now" re-prompts for 2FA: pulling code and
restarting is the most privileged thing the panel can do, so a session
alone is not the right bar -- same gate as minting a token.

Both are switchable: features.updates.enabled is the air-gap switch (no
egress, no tools, no notice) and allow_self_update keeps the notice while
forbidding the apply, for deployments where updates go through a pipeline.

Also fixes __version__, which had been pinned at "0.1.0" while pyproject
said 2.0.0 -- it now reads package metadata, with the real number as the
source-tree fallback.

Tests drive git for real against throwaway repositories: a mocked
subprocess would only prove the mock agrees with itself. pip and the
validation subprocess are the two steps stubbed, so the pull/validate/
roll-back orchestration is exercised without touching the interpreter
running the suite.

* fix(updates): mention updates on the post-2FA screen, and stop caches pinning old assets

Two gaps found by actually looking at the rendered pages.

**The "You're signed in" screen said nothing.** The toast fetches its
status once at page load, which on /app/login happens before the session
exists -- so it 401'd and stayed empty, and signing in never re-checks
because it does not reload the page. The one moment the operator is
guaranteed to pass through said nothing about a pending update.

login.js now re-asks once the session is created and renders a one-line
mention above "Finish signing in". Deliberately not the full card: that
screen has a single primary action, and on a narrow viewport a
bottom-anchored card this tall would sit on top of it. The card now opts
out of the auth pages entirely and shows on the landing page instead.

**Browsers could keep running the previous release's JavaScript.**
Starlette serves static files with ETag/Last-Modified but no
Cache-Control, which leaves browsers on heuristic freshness -- a file
untouched for weeks is reused for a long time without ever revalidating.
That was survivable when upgrading meant running commands by hand; it is
not once the server can update itself and the next page load is expected
to match the new backend. This was not theoretical: it bit the browser
used to verify the change, which kept executing a stale bundle across
several restarts.

Asset URLs now carry a fingerprint of the bundle, recomputed at start
from the newest mtime in the static directory (which a git pull bumps).
New bytes mean a new URL, so no cache can serve it from an old entry --
which also lets the files be cached hard instead of revalidated:

  ?v= present  -> public, max-age=31536000, immutable
  ?v= absent   -> no-cache (a legacy or hand-typed URL can't pin old code)
  /app/* pages -> no-store (per-session, and they carry the fingerprint)

* fix(updates): serialize update work, and keep the restart off the shell

Self-review findings on the update flow.

**Two updates could run at once.** The dashboard button and the MCP tool
reach `apply_update` independently, so nothing stopped a second one
starting mid-pull: two `git pull` / `pip install -e .` runs in one
checkout fight over index.lock and can leave a half-applied tree, and one
caller's rollback could discard the other's successful update. A second
caller is now told an update is already running rather than queued behind
a pip that may take minutes -- it never touches git.

**Cold-cache checks stampeded.** Every dashboard tab opening at once fired
its own `git fetch`, piling up 60 s subprocesses for one answer. The
uncached path is now single-flighted; waiters get the result the winner
cached.

**The deferred restart built a shell string.** `service` is the literal
"beaconmcp" today, so this was not exploitable, but interpolating it into
`sh -c` means a future change that made the unit name configurable would
silently become a shell injection. Values now go through argv.

All three are covered by tests, and both locks were mutation-checked:
removing either makes its test fail (4 concurrent checks instead of 1;
"release unlocked lock" when the second updater proceeds).
Closes #35. The ui:// panels from #34 only rendered in external hosts;
/app/chat showed the tool's JSON. The dashboard is both the MCP client
and the host, so both halves were missing.

Not blocked by mcp 2.0 after all. A client declares Apps support through
ClientCapabilities.extensions, which 1.x has no attribute for -- but the
model is declared extra="allow", so the field serialises under the name
the spec gives it and the server reads the same JSON either way. The pin
costs the typed attribute, not the capability.

Client half (dashboard/mcp_bridge.py, chat.py): an AppsClientSession that
tags the outgoing InitializeRequest rather than reimplementing
initialize(), the tool -> ui:// map read off each tool's _meta, and the
full CallToolResult carried on ToolCallEnd -- the panel needs the whole
payload, not the 500-char preview the tool card shows.

Host half (chat.js, two routes): the document is served by
/app/api/mcp/panel under its own CSP and framed with sandbox="allow-
scripts" and no allow-same-origin. Verified in a browser: the frame gets
a SecurityError on document.cookie and on window.parent, and CSP blocks
fetch. Its only way out is postMessage, which is what makes the parent
page the place where policy is decided. chat.js answers ui/initialize,
pushes tool-input/tool-result, relays tools/call through
/app/api/mcp/call, routes ui/message into a real turn and
ui/update-model-context into the next one, and honours size-changed and
request-display-mode.

The confirmation question #35 raised, decided: a panel button is a human
click on a labelled control, so it is not gated -- but "calls from an
iframe skip the gate" is not the rule. A ui:// document is HTML the
server wrote and this dashboard is a general MCP host, so the exemption
is a closed list enforced in panel_call_allowed(): start/stop/restart on
one guest, and proxmox_vm_config only for sizing keys (exempting the tool
would exempt hookscript, raw QEMU args and device passthrough with it).
Everything else is refused rather than prompted, because there is no turn
in flight to hang a modal on -- and a panel that needs more sends
ui/message, which puts the request back under the modal.

Only the ui:// URI is persisted with a tool call, never the snapshot: a
panel reopened from history refetches rather than showing week-old
figures in a live-looking frame.

Also fixes the panels' theming against a real host: panel.css now reads
the spec's standardized variable names with its own values as fallbacks,
so hostContext.styles.variables actually lands.

505 passed (+44), ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…h-Lite / 3.1 Pro

Gemini 3.6 Flash went GA on 2026-07-21 and supersedes gemini-3-flash-preview;
3.5 Flash-Lite is the Flash-Lite that shipped alongside it, and lands at the
price 2.5 Flash used to hold. Gemini 2.5 Flash / Pro and gemini-3-flash-preview
leave the picker; 3.1 Pro stays as the preview option.

There is no gemini-3.6-flash-lite -- the Lite in that launch is 3.5.

Rates (AI Studio, 2026-07-29): 3.6 Flash $1.50/$0.15/$7.50, 3.5 Flash-Lite
$0.30/$0.03/$2.50. 3.1 Pro is unchanged. The retired models keep their entries
in _PRICING: cost_usd re-prices stored turns, so dropping a rate would silently
re-bill that history at the fallback model's price.

Schema 6 moves conversations off the retired ids -- conversations.model is what
the *next* turn runs on, and a retired id there would fail VALID_MODELS and
silently fall back, reading as the picker forgetting the operator's choice.
messages.model is deliberately left alone: it records which model actually
wrote a reply, which is history rather than configuration. That is the
difference from migration 2, which renamed the same model.

Also fixes an unrelated fragility in test_fingerprint_changes_when_an_asset_changes:
it bumped app.css past its own mtime, but the fingerprint is the directory
maximum, so the assertion failed whenever another static file happened to be
newer.

510 passed, ruff clean. Picker verified in the browser: chip reads "3.6 Flash",
groups Flash / Pro, 3.1 Pro carries the Preview badge.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Found reviewing this branch before merge. Enumerating the 49 registered
tools against _NEEDS_CONFIRMATION turned up beaconmcp_self_update sitting
outside it: with confirm=True it runs git pull, reinstalls dependencies
and restarts the service, so one injected instruction in a log line could
replace the process that enforces the gate. It landed ungated with the
self-update tools in #37; the panel relay added here would have inherited
the hole.

_CONFIRM_WHEN_ARG_PRESENT rather than _NEEDS_CONFIRMATION: confirm=False
only previews. Reading the argument is sound here because `confirm` is a
parameter the tool declares -- the trap the dry_run note describes is an
argument the tool does *not* declare, which pydantic drops during
validation.

Adds a test that walks every @mcp.tool in src/ and fails on any name that
is neither gated nor on an explicit reviewed-as-safe list, so the next
tool cannot land outside the gate unnoticed.

Also restores the composer text when submit() fails before the message is
rendered -- moving the clear ahead of sendUserText() for ui/message made a
failed conversation-create eat what the operator typed.

513 passed, ruff clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@Showdown76py
Showdown76py marked this pull request as ready for review July 29, 2026 14:45

@Showdown76py Showdown76py left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Self-review before merge

Reviewed main...feat/mcp-apps-panels (49 files, ~3.3k added). Two things were fixed during the review rather than filed; the rest is context.

Fixed here

🔴 beaconmcp_self_update was outside the confirmation gate. Enumerating all 49 registered @mcp.tools against _NEEDS_CONFIRMATION turned it up. With confirm=True it runs git pull, reinstalls dependencies and restarts the service — one injected instruction in a log line, a config file or a search result could replace the process that enforces the gate. It landed ungated with the self-update tools in #37, and the panel relay added on this branch would have inherited the hole.

Now in _CONFIRM_WHEN_ARG_PRESENT (confirm=False only previews, so it stays a plain read). Reading the argument is sound here — confirm is a parameter the tool declares, which is exactly what the dry_run note says is missing in the unsound case.

A new test walks every @mcp.tool in src/ and fails on any name that is neither gated nor on an explicit reviewed-as-safe list, so the next tool cannot land outside the gate unnoticed.

🟡 The composer ate the message on a failed send. Moving the clear ahead of sendUserText() (needed so ui/message shares the path) meant a failed conversation-create discarded what the operator had typed. Restored on error.

Noted, not changed

  • Panel documents are cached by (mcp_url, uri), not per client. A cache hit skips read_ui_resource, which is where the MCP-side read happens. Harmless today — panel documents are static assets identical for every authenticated client, and this server has no per-client resource gating — but a server that gained one would not be consulted on a hit.
  • One MCP session per relayed tools/call. A full handshake per button click. Correct and stateless; a pooled session would be faster at the cost of lifecycle handling.
  • The MIME check is exact-match after stripping spaces, so text/html; profile="mcp-app" (quoted parameter) would be refused. Deliberately strict — it is the gate that stops this route being a general resource proxy.
  • Un-gated tools remain relayable from a panel, including proxmox_read_file. That is the documented rule ("everything that was never gated in the first place"), and it is the same surface the model already has unattended.

What holds up

The isolation was checked in a browser rather than assumed: inside the frame, document.cookie and window.parent.location both raise SecurityError, and fetch('/app/api/mcp/call') is blocked by CSP. ssh_run from the frame comes back refused with the right message. So the sandbox is doing what the comments claim, and the relay is genuinely the only way out.

The ui/initialize handshake was validated against the panels' real bridge.js served by the real routes — the failure mode #34's description warns about — plus the full round trip: tool result → button → relay → ui/update-model-context landing in the next turn's request body, and ui/message producing a real user turn.

513 passed, ruff check src/ tests/ clean.

Verdict

Approve — with the caveat that GitHub will not let me record it as one: I authored this PR, so addPullRequestReview rejects an approval from this account.

@Showdown76py
Showdown76py merged commit 87caf1a into main Jul 29, 2026
6 checks passed
@Showdown76py
Showdown76py deleted the feat/mcp-apps-panels branch July 29, 2026 14:52
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