mikser-io-mcp-app
v11.4.0
Published
MCP Apps (SEP-1865) for mikser-io. Layouts declaring `mcpApp` frontmatter become interactive apps: a predeclared ui:// shell resource, per-entity rendering into it, and user actions delivered back as tool calls or to a webhook.
Maintainers
Readme
mikser-io-mcp-app
MCP Apps for mikser-io — interactive UI over MCP, served on its own route, with a layout as the app.
Implements SEP-1865, the accepted MCP Apps extension. Not mcp-ui. The two are easy to conflate and the difference decides the wire shape: mcp-ui returns the UI inside the tool result as an embedded resource, while MCP Apps predeclares it — the app is a resource at a ui:// URI, a tool points at it through _meta.ui.resourceUri, and each call's data reaches the iframe as structuredContent. SEP-1865 considered the embedded shape and deferred it, so this package implements the predeclared one and nothing else.
An app is a layout
---
match: "@/orders/*"
mcpApp:
mode: approve
description: Approve an order
actions: [approve, reject]
---<button onclick="sendAction('approve', { note: 'looks right' })">Approve</button>That is the whole authoring surface. The layout is a body fragment — no doctype, no protocol code. The shell supplies the document, the handshake and sendAction, so the protocol can change without touching content.
Because an app is a layout matched against an entity, it is per-entity rather than per-server: the mechanism that renders a page, pointed at an iframe.
Install
npm install mikser-io-mcp-appimport { mcp } from 'mikser-io-mcp'
import { mcpApp } from 'mikser-io-mcp-app'
export default async ({ options }) => ({
plugins: [
...pipeline(),
// mcp() first: it provides the substrate this mounts on.
options.server && mcp({ base: '' }),
options.server && mcpApp(),
],
})Both behind --server — there is no route without an HTTP server, and a plugin whose surface silently never appears is worse than one that refuses.
Its own route
mcpApp() mounts at /app, separate from /mcp, and every tool and resource it registers is scoped to that endpoint. Two reasons:
- an app host connects to a route whose
initializedeclares the extension and whose tool list is the app surface and nothing else; mikser_app_actionis app-callable (_meta.ui.visibility: ['app']) — the spec says a host must keep it out of the model's tool list, so it has no business on the agent's endpoint.
The route carries this surface and nothing else — two tools and two resources. A host connects here to run an app and has no use for mikser_delete_entity, and every write tool on a second route is another way to reach it. The agent's tools stay on /mcp:
| | /mcp | /app |
|---|---|---|
| tools | 21 (the whole mikser surface) | 2 — mikser_app_preview, mikser_app_action |
| resources | 7 mikser://… | 2 — the shell and the modes list |
| extension declared | no | yes |
Sessions, transport, the auth rule and the protected-resource metadata stay in mikser-io-mcp; this package asks for a route rather than hand-rolling one.
| Option | Default | |
|---|---|---|
| name | 'app' | endpoint name, and what registrations scope themselves to |
| path | /<name> | where it mounts |
| auth | — | a verifier (mikser-io-auth's oauth() / jwt(), or any { verify }) |
| token | — | static-secret shorthand; keeps mikser's loopback-trust model |
| allowRemote | false | serve to non-loopback callers with no credential |
| renderTimeout | 30000 | ms for one app render |
| tools | the two app tools | what of the tool surface this route exposes; [] exposes none, null exposes everything |
| resources | the shell and the modes list | same, for resources |
| prompts | [] | same, for prompts |
Restricting an app
An app is public unless its layout says otherwise. Name the groups that may use it:
mcpApp:
mode: approve
actions: [approve, reject]
auth: [editors, admins]Groups are the principal's roles — the ones in groups.htgroup — because a group is what a layout author can reason about. A layout with no auth key stays public, so upgrading changes nothing.
The check runs before the JSON-RPC dispatch, through mikser-io-mcp's per-call hook, and that placement is the point. A tool handler can only return a tool result, and a result saying "not allowed" is a successful response that no host reads as "sign in" — the user would be refused with no way to authenticate. Refusing the POST instead means:
| | |
|---|---|
| nobody signed in | 401 with the WWW-Authenticate challenge, which is what makes a host's "required when the server asks" flow start |
| signed in, wrong group | 403 — signing in again will not help, and a client that reads 401 here loops on a refresh that cannot fix anything |
It covers every door into the layout, because gating one leaves the rest open:
mikser_app_preview— the app itself;mikser_app_action— the click, reachable without ever rendering the app;resources/readundermikser://app/<layout>/…— the data behind it;mikser://mcp-app/modesand the data listing — a listing that names restricted apps hands an anonymous caller their descriptions and action names.
One thing to get right in config: a route mounted allowRemote: true with no verifier has no identity to check, so a restricted layout there can only ever deny. Give the route auth: identity.oauth() for sign-in to be possible at all.
The surface
| | |
|---|---|
| ui://mikser/app-shell | the app, text/html;profile=mcp-app. Predeclared, static, reviewable before any tool runs |
| mikser://mcp-app/modes | live discovery — which modes exist and what each matches, from layout frontmatter |
| mikser_app_preview | render an entity through its mcpApp layout into the shell |
| mikser_app_action | deliver a click; app-callable only |
What happens on a click
sendAction(action, payload?) → tools/call mikser_app_action over the host's bridge → the action is checked against the layout's declared actions list → { entityId, action, payload } comes back as the tool result, and the agent decides what it means.
The allow-list is the auth boundary; there is no callId, signed URL or token on this channel, because the iframe's only route here is the host's already-authenticated MCP transport.
What an action means: the layout's sidecar
<layout>.js — the same sidecar file whose load export the render already uses — answers for the app through three more named exports:
// layouts/order.js
export async function call({ action, payload, entity, layout, mode, principal, logger }) {
if (action === 'approve') return { ok: true, id: entity.meta.id }
}
export async function list({ layout, principal, logger }) {
return [{ path: 'rows', name: 'Order rows', mimeType: 'application/json' }]
}
export async function read({ path, uri, layout, principal, logger }) {
if (path === 'rows') return { rows: [/* … */] }
}callreceives a declared action — theactionslist is checked first, so project code never sees an action the layout didn't offer. Its return value is the tool result the app sees; returning nothing still counts as handled. Throwing reports the failure naming the file, rather than losing the click.listandreadback the app'slistServerResources()andreadServerResource(). The sidecar names apath; mikser builds the URI undermikser://app/<layout>/<path>, so a project never constructs mikser's URI space.readmay answer with a string, a{ text | blob, mimeType }envelope, a full{ contents: [...] }, or any object (serialised as JSON — amimeTypekey in a data object stays data).principalis who called, when the route is gated; on a public route it'sanonymous— a name, not a person, which is why a sidecar validates rather than trusts.
Sidecars load through the layouts service, not an import — this package declares no dependency on mikser-io-layouts and contains no reference to it beyond the service name. What it needs is the contract: something providing layouts with a sidecar(layout) method, which mikser-io-layouts ≥ 11.2.0 does. Going through the service rather than copying the loader is what makes an edited handler take effect under --watch, by the same digest rule the render uses.
Without that service the app surface still renders and still relays actions; only the handlers go unreached, and mcpApp says so once at load rather than leaving it silent.
An earlier version let a layout name an HTTP handler.url that mikser POSTed each action to, HMAC-signed. It is gone: an entire webhook protocol — an endpoint to mount, a signature to verify, a timeout, and a state where a click was neither relayed nor handled — to reach code already sitting in the project. A layout that still declares the block gets a plain relay; nothing is POSTed. Its successor is a handler beside the layout, in-process, which is where an action's meaning belongs.
The shell is built, not hand-written
The protocol inside the iframe is the official SDK — @modelcontextprotocol/ext-apps — bundled into one self-contained document by vite + vite-plugin-singlefile, which is what the SDK's own add-app-to-server skill prescribes. The iframe has no network (the spec's CSP is default-src 'none'), so a build that emitted separate assets would produce a page whose scripts can never load.
npm run build # src/app/{index.html,main.js} -> public/app-shell.htmlThe built file is committed and published, and prepack rebuilds it, so installing needs no build and a stale artefact cannot ship. What lives in src/app/main.js is only the part that is mikser's: take the rendered layout out of structuredContent, put it in the page, and give the layout sendAction. Handlers are registered before connect(), per the SDK's guidance — a result arriving during the handshake is otherwise dropped and the app renders empty.
Because the runtime is the SDK's, layouts also get its behaviour for free: host theme and fonts (applyDocumentTheme, applyHostStyleVariables), safe-area insets, iframe size notifications, and _meta["ui/resourceUri"] emitted alongside the modern key so hosts on the older spelling still resolve the app.
If nothing renders
A conformant host renders an app only for a server that declared the extension at initialize:
"capabilities": { "extensions": { "io.modelcontextprotocol/ui": { "mimeTypes": ["text/html;profile=mcp-app"] } } }mikser-io-mcp derives that from the ui:// resources actually bound on the route, so registering here switches it on — under the SDK's own EXTENSION_ID, pinned by a test so the two cannot drift. If a host still shows text, it does not implement the extension: that is the correct fallback, and content[0].text carries the rendered HTML so the user sees something either way.
When something does break, the shell shows one line — a failed handshake, a call that threw — and nothing on the happy path. These routes serve a site's visitors, so a protocol log under a customer's form is a leak, not a diagnostic; the detail goes to the host through the SDK's sendLog.
Migrating from mcpUi
This feature lived in mikser-io-mcp under the mcp-ui vocabulary. Renamed on the way out, with no aliases — a layout still on mcpUi is not eligible, deliberately and under test:
| was | is |
|---|---|
| mcpUi: frontmatter | mcpApp: |
| mikser_preview_ui | mikser_app_preview |
| mikser_ui_action | mikser_app_action |
| ui://mikser/preview-ui-shell | ui://mikser/app-shell |
| mikser://mcp-ui/modes | mikser://mcp-app/modes |
| served on /mcp | served on /app |
Decisions
ADR-0001 — the predeclared shell, tools/call delivery, and the optional webhook, including the alternatives ruled out.
License
MIT
