Skip to main content
Glama

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.

Related MCP server: Hosted MCP Apps Example Gallery

Install

npm install mikser-io-mcp-app
import { 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 initialize declares the extension and whose tool list is the app surface and nothing else;

  • mikser_app_action is 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/read under mikser://app/<layout>/… — the data behind it;

  • mikser://mcp-app/modes and 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: [/* … */] }
}
  • call receives a declared action — the actions list 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.

  • list and read back the app's listServerResources() and readServerResource(). The sidecar names a path; mikser builds the URI under mikser://app/<layout>/<path>, so a project never constructs mikser's URI space. read may answer with a string, a { text | blob, mimeType } envelope, a full { contents: [...] }, or any object (serialised as JSON — a mimeType key in a data object stays data).

  • principal is who called, when the route is gated; on a public route it's anonymous — 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.html

The 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

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    Enables users to interact with a gallery of sample interactive MCP Apps, such as a budget allocator or cohort heatmap, by connecting a remote Streamable HTTP URL.
    Apache 2.0
  • A
    license
    Not graded
    quality
    B
    maintenance
    Enables agents to serve interactive Auth0 Forms as MCP Apps in a sandboxed iframe, letting users complete sign-up, consent, profile, or payment flows while the agent only receives completion status.
    MIT
  • F
    license
    Not graded
    quality
    B
    maintenance
    Provides a production-shaped FastMCP server with embedded MCP App UIs, including authentication, licensing, usage reporting, manifest and health routes, container setup, and deployment workflows.
    1
    -