Vue MCP Apps
Add a credential-free Vue interface to an MCP tool without changing its authorization boundary.
@lupinum/better-convex-vue/mcp-app is an experimental Vue lifecycle integration for
the official MCP Apps SDK. It lets a supporting host render a ui:// resource
in a sandboxed iframe while the same tool remains useful to clients that do not
support Apps.
The integration is intentionally narrow. The official SDK owns App Bridge and wire behavior. Better Convex owns Vue mount, disposal, readonly reactive projections, and structured-clone boundaries. Your application still owns the tool, resource, authorization, data, and effects.
Install the optional entry
Install the complete exact Apps peer set only when the application builds an MCP App:
pnpm add @lupinum/better-convex-vue@0.8.0-beta.40 convex@1.42.2 vue@3.5.40 @modelcontextprotocol/ext-apps@1.7.5 @modelcontextprotocol/sdk@1.30.0 zod@4.4.3Importing ordinary @lupinum/better-convex-vue does not pull the Apps SDK into the
client graph. The SDK and Zod entries satisfy the non-optional peers declared
by the official Apps package under strict package managers.
Own one App per Vue scope
Create the lifecycle synchronously in component setup:
<script setup lang="ts">
import { useMcpApp } from '@lupinum/better-convex-vue/mcp-app'
import type { McpAppError } from '@lupinum/better-convex-vue/mcp-app'
import { computed, watch } from 'vue'
const app = useMcpApp({
implementation: {
name: 'notes-dashboard',
version: '0.1.0',
},
capabilities: {},
})
const canSearch = computed(() => app.phase.value === 'ready' && app.toolInput.value !== undefined)
watch(app.toolResult, (result) => {
// Validate and project only the fields this interface renders.
receiveSearchResult(result)
})
watch(app.error, (error: McpAppError | undefined) => {
if (error) showSafeLifecycleFailure(error.code)
})
async function refresh() {
if (!canSearch.value) return
const result = await app.callServerTool({
name: 'search_notes',
arguments: validatedSearchInput(),
})
receiveSearchResult(result)
}
</script>useMcpApp() must run synchronously during browser component setup. It fails
before constructing the official App when called during SSR or outside an
active component instance. It exposes one canonical phase, one readonly
sanitized lifecycle error, readonly shallow refs for official host and tool
notifications, and only two host operations:
callServerTool()asks the host to perform an ordinary MCP tool call;openLink()asks the host to navigate to an external URL.
Both operations reject before initialization, structured-clone inputs and
outputs, and reject results that complete after the Vue scope is retired. A
tool-call or link rejection belongs to that operation: it rejects the caller
without changing the connection phase or lifecycle error. Scope disposal
removes Better Convex listeners, clears projected state, and closes the private
SDK App exactly once. The mutable SDK App is never exposed.
Connection and structured-clone failures are terminal. They close the private App and expose only stable local diagnostics:
| Code | Meaning |
|---|---|
MCP_APP_CONNECT_FAILED | The App could not complete the host handshake |
MCP_APP_CLONE_FAILED | A bridge value could not be safely copied |
The error object never includes the host error, rejected value, raw cause, stack, bridge transcript, or credentials. Log application-owned context around the stable code if you need telemetry; do not reconstruct a raw-host escape hatch. Host-context change notifications remain partial in the protocol. The official App merges them into its current context before this composable clones the merged snapshot.
Automatic resizing is deliberately unavailable. The exact SDK does not expose the cleanup required to retire its resize observer, so Better Convex constructs the App with automatic resizing disabled.
Register one truthful resource
Register the ui:// resource and its tool metadata directly with the official
MCP server created by @lupinum/better-convex-mcp. Better Convex does not add another
Apps registry or server protocol wrapper.
The HTML document should be a bounded production bundle with:
- a restrictive Content Security Policy;
- unused validation-library locale barrels excluded from the browser bundle;
- no dynamic application data interpolated into executable HTML;
- no credentials, provider references, raw errors, or Convex client;
- validated rendering of every tool input and result; and
- useful text and structured tool output for clients that do not render it.
Capability negotiation controls whether the host presents the App. It must not fork the canonical tool/resource inventory or change application authority.
Authorization remains ordinary MCP authorization
An iframe button grants no permission. callServerTool() goes through the
host, the MCP bearer boundary, the tool schema, and current application
authorization just like a model-originated call.
The iframe must never receive:
- an MCP bearer token;
- a Better Auth cookie, session, or provider-private identifier;
- a Convex JWT or raw Convex client;
- a refresh token or internal service proof;
- application signing keys;
- raw request headers, arguments from another operation, or raw error causes.
Recheck current membership, role, delegation, credential state, and target resource inside the canonical application operation. OAuth scopes and App visibility are ceilings and presentation metadata—not current application authorization.
Navigation is not confirmation
openLink() is host-mediated navigation. A successful link response does not
mean the human approved anything and does not grant the opened page authority.
Ordinary writes should remain ordinary tools. If an application requires step-up authentication or a human review for a high-blast-radius effect, the authoritative application must authenticate the person, reload current authority and impact, and perform its own state-changing request. The App can display validated impact and navigate to that application; it cannot approve or execute the protected effect merely because it rendered a button.
Progressive fallback
Always return useful model-visible content for a model-visible tool:
return {
content: [
{
type: 'text',
text: 'Found 3 notes. The dashboard can refine these results.',
},
],
structuredContent: {
matches: minimizedMatches,
},
}A baseline client can use this result without the extension. A capable host may also render the App resource and deliver the same validated result through App Bridge. Host denial, missing capability, iframe teardown, or link denial must not corrupt the canonical operation or fabricate success.
Experimental limits
The mcp-app entry remains experimental:
@modelcontextprotocol/ext-apps@1.7.5logs parsed and sent bridge messages and exposes no logger control;- different-origin and compatible real-host evidence is still required; and
- the Apps server helper is coupled to the v1 combined MCP SDK while
@lupinum/better-convex-mcpuses the locked split v2 SDK for the final protocol.
Better Convex therefore uses the official Apps client directly and registers server resources through the selected official v2 server. It does not ship two MCP server runtimes, cast between SDK majors, or provide a compatibility protocol.
The exact beta candidate has passed production Vite browser proofs for a neutral notes dashboard and Ginko's materially different publish-impact interface, including installed-byte checks, fallback behavior, CSP/sandbox, wrong-source messages, malicious results, teardown, current authorization, and credential sentinels. Those local proofs do not substitute for protected deployment and real-host evidence before stable admission.