mikser-io-mcp-app
This server does not currently have a deployable release on Glama. Follow the repository's setup instructions to install and configure it for your MCP client, including any required credentials and local dependencies.
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: @auth0/agent-components
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:
|
| |
tools | 21 (the whole mikser surface) | 2 — |
resources | 7 | 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 | |
|
| endpoint name, and what registrations scope themselves to |
|
| where it mounts |
| — | a verifier ( |
| — | static-secret shorthand; keeps mikser's loopback-trust model |
|
| serve to non-loopback callers with no credential |
|
| ms for one app render |
| the two app tools | what of the tool surface this route exposes; |
| the shell and the modes list | same, for resources |
|
| 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 |
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
| the app, |
| live discovery — which modes exist and what each matches, from layout frontmatter |
| render an entity through its |
| 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 |
|
|
|
|
|
|
|
|
|
|
served on | served on |
Decisions
ADR-0001 — the predeclared shell, tools/call delivery, and the optional webhook, including the alternatives ruled out.
License
MIT
Tool Schema Changelog
Recent tool additions, removals, and schema changes observed during successful MCP inspections.
Loading tool schema changelog…
This server cannot be deployed
Related MCP Connectors
Renders dashboards, forms and tables inline in the chat as Angular Material components (MCP App).
Governed MCP gateway for business apps: roles, previews, approvals and one audit log.
Build, deploy, and operate hosted web apps on VibeKit (vibekit.bot) from any MCP client.
Create, edit, preview, and deploy full-stack web apps to a live URL from any MCP client.
Related MCP Servers
- AlicenseNot gradedqualityDmaintenanceEnables 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
- AlicenseNot gradedqualityCmaintenanceEnables 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
- FlicenseNot gradedqualityBmaintenanceProvides 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-
- FlicenseNot gradedqualityCmaintenanceEnables developers to create and deploy FastMCP servers with embedded MCP Apps for interactive tool-to-UI flows. It also provides production-ready authentication, licensing, usage reporting, and health-check endpoints.-