Skip to main content

WebMCP integration model

See the MCP Apps integration standard for portable embedded UI, optional OpenAI extensions, ownership and staged release gates.

WebMCP is the browser-facing capability surface for an s-m-r-t application. A good integration composes narrowly scoped tools from four sources. The application's authenticated REST routes remain the authorization boundary; WebMCP exposure only decides which capabilities a model can discover.

The four tool sources​

SourceBest forRegistration model
Generated model toolsReading and mutating a model through generated RESTwebMcpToolDefinitions with the Provider, or registerWebMcpTools() from @happyvertical/smrt-web
Mounted UI toolsInspecting a mounted Form or DataSurface and requesting a user-reviewed action<Provider webmcp={{ ui: ... }}>
Bespoke component toolsOne intent owned by one componentuseWebMcpTool(() => spec) from @happyvertical/smrt-svelte
Declared view intentsA component-owned interaction that is static, namespaced, and emittable into the manifestdefineIntent({ ... }) in a *.intents.ts sidecar from @happyvertical/smrt-web/intents, bound with useViewIntent(intent, { identity }) from @happyvertical/smrt-svelte
Principal-bound server toolsServer-side work that must never be browser-exposedServer-owned tools run with executeAsPrincipal on the authenticated server path (see #2450)

Keep these namespaces and trust boundaries distinct. Generated model tools describe data capabilities; mounted UI tools describe transport-neutral interaction state; bespoke tools and declared view intents describe a component-owned intent; and principal-bound tools stay behind server authorization.

The four browser sources derive their tool names independently and all reach one document.modelContext, so each reserves its names through a document-global tool-name lock before registering (#2613). A name two sources both want is refused synchronously, with a message naming the tool and which source already holds it (generated, ui, intent, or bespoke). useViewIntent labels its reservation intent even though it registers through the bespoke registrar, so an intent collision names the intent rather than a hand-written tool. Before the lock, the browser rejected whichever registration arrived second and that tool was simply absent. Disposing a registration releases its names, so mounting and unmounting the same tool repeatedly keeps working.

"Synchronously" describes the registrar's own contract — it throws before calling registerTool. The Svelte bindings load @happyvertical/smrt-web lazily, so from a component's point of view the report still arrives a microtask or two after mount: <Provider> logs the throw through its logger, and useWebMcpTool reports it as a console.warn naming the tool and owner. Neither surfaces a collision as an exception the component can catch.

Declared view intents and bespoke component tools are the same trust boundary reached two ways, and both register through registerWebMcpBespokeTool under one exposure policy. Prefer a declared intent: it is static, so it classifies at declaration, carries a stable namespaced identity a playbook step can name, and can be emitted into the manifest and knowledge graph. useWebMcpTool stays the escape hatch for a tool set derived from fetched data, which no static form can express.

Generated model tools​

The template wires the generated definitions into its root Provider:

<Provider
webmcp={{
definitions: webMcpToolDefinitions,
basePath: '/api',
effects: ['read'],
}}
>
{@render children()}
</Provider>

Omitting effects (or selecting ['read']) exposes reads only. Add write or destructive only for a page that needs them, and bind the surface with a namespace, maxTools, and a canonical filterTool where appropriate. Policy validation is atomic: duplicate names, malformed definitions, or a filter mismatch reject the prospective registration before any browser tool is installed. Use one complete definition source for a collection; do not mix legacy and canonical definitions for the same collection.

Tool results are untrusted application content. Do not treat annotations as authorization, and do not place secrets or hidden fields in a definition. Generated fetchers use the page's authenticated REST transport, so auth, tenant, writable-field, and sensitive-field checks remain server-side.

Mounted UI tools​

Enable the fixed UI adapter when a page has mounted forms or data surfaces:

<Provider webmcp={{ ui: { dataSurfaceRegistry: surfaces } }}>
{@render children()}
</Provider>

The adapter exposes six stable smrt_ui_* tools for listing, inspecting, and executing form controls and DataSurface controls. It reads the current transport-neutral registries at execution time and never inspects the DOM. Form mutations can be staged by an agent, but apply, clear, and undo require a separate human-confirmed path. Agent input cannot assert confirmation. Secret values and hidden columns are omitted from serialized results.

Forms, lists, menus, and wizards​

  • A rich <Form webmcp> (or FormScope around content with no <form>) registers one <formId>_stage_changes tool covering every control the form holds: rich fields, smrt-ui primitives, and composites registered with useControlRegistration. It only stages proposals; the review panel shows plain field labels and a person applies them. The rich Form forwards native form attributes and takes SvelteKit's enhance as a prop.
  • useListSurface mirrors a rendered list (with a find text lookup over searchable columns), useLinkSurface makes a menu or tab row navigable, and useStepSurface lets an agent move a wizard forward — revealing, never pressing, a step button that saves.

Declared view intents​

A view intent is an interaction a component owns that has no model projection and never will — filter this list, advance the wizard, open the archived tab. Declare it as a literal object at module scope in a .ts sidecar, so the scanner can find it without evaluating the module:

// OrderTable.intents.ts
import { defineIntent } from '@happyvertical/smrt-web/intents';

export const nextPageIntent = defineIntent({
id: 'orders.next_page',
description: 'Advance the orders table by one page',
capability: { effect: 'read', idempotent: false, openWorld: false },
target: { registry: 'dataSurface', controlId: 'next-page', kind: 'table' },
});

Bind it to a mounted registry identity for the component's lifetime:

<script lang="ts">
import { useViewIntent } from '@happyvertical/smrt-svelte';
import { nextPageIntent } from './OrderTable.intents.js';

useViewIntent(nextPageIntent, {
identity: { surfaceId: 'orders-table', kind: 'table' },
});
</script>

Three properties follow from the declaration being data:

  • It classifies at declaration, through the same fail-closed rule as a generated model action. Omit capability and the intent resolves to destructive, non-idempotent, open-world, and a read-only Provider excludes it.
  • It cannot reach REST. The declaration type has no execute, url, route, or fetch field, defineIntent rejects any unknown key and any function value anywhere in the object, and the tool's execute is constructed by the registry from target. An intent dispatches exactly one ControlInteractionRegistry or DataSurfaceRegistry command, as source: 'agent' — so StagedControlReview stays on the path and an agent-staged value remains a proposal.
  • It is emittable. The stable namespaced id is what a playbook step names ({ kind: 'intent', id }) and what the manifest will carry.

The contract, the registry, and the compilation live in @happyvertical/smrt-web with no Svelte dependency; Svelte is the first binding, not the only one.

Bespoke component tools​

Use useWebMcpTool for an intent that belongs to one component rather than to the application data model:

<script lang="ts">
import { useWebMcpTool } from '@happyvertical/smrt-svelte';

useWebMcpTool(() => ({
name: 'invoice_preview',
description: 'Preview the current invoice without changing it.',
inputSchema: { type: 'object', properties: {} },
annotations: { readOnlyHint: true, openWorldHint: false },
execute: () => JSON.stringify({ ok: true }),
}));
</script>

The hook feature-detects document.modelContext, is a no-op during SSR and in browsers without WebMCP, and aborts the component's registration on unmount or when its reactive specification changes. Use a stable, component-specific name and return bounded, untrusted-content-safe results.

useWebMcpTool routes through the same registrar as generated tools, so a bespoke tool is subject to the same fail-closed effects exposure policy: no annotations, or annotations that leave the effect undeclared, classify destructive, non-idempotent, open-world — the tool above must declare readOnlyHint: true to register under a read-only policy. The policy is the nearest <Provider webmcp={{ effects: [...] }}>'s configured effects; without a Provider ancestor, or without effects configured, only reads are exposed. Unlike generated tools, a bespoke tool never receives the namespace prefix and is never counted against maxTools — a component author already chose a stable name, and budgeting one intent against a shared generated-tool set could make an unrelated tool fail to register.

The in-page assistant sees the same tools​

The browser's modelContext is write-only from page script, so an in-page assistant cannot list the tools an outside agent sees. Install the page tool registry once, early in the app's root layout and before the first registration:

import { installWebMcpPageToolRegistry } from '@happyvertical/smrt-web/webmcp-page-tools';

const pageTools = installWebMcpPageToolRegistry(); // undefined during SSR

It puts a recording modelContext on the document that keeps every registration from all four sources (and forwards it to the browser's native context when there is one, so outside agents are unaffected). list() returns the live tools with their resolved effect, owner, and proposal flag, and execute(name, args) runs one through its own registered execute, so every effect, consent, and REST gate stays where it is. Registrars stamp their resolved effect on the registration (WEBMCP_TOOL_EFFECT), because the annotations they send the browser mark every non-read tool destructive; a registration without a stamp falls back to the annotations. owner is the tool-name lock's diagnostic label, which a bespoke caller can set; only proposal (a module-private brand that compiled view intents and the fixed smrt_ui_* tools carry, applied with markWebMcpProposalTool from @happyvertical/smrt-web/webmcp-tool-names) marks a write as a consent-gated proposal. Pass the registry to AssistantDock's pageTools (see docs/assistant-dock.md).

Lifecycle and verification​

WebMCP registration is client-only. A missing modelContext must not prevent SSR or hydration. Every registration should be disposed or aborted with its own Provider/component lifecycle. Test the complete composition with a real in-memory SQLite database and generated REST handlers; double only document.modelContext and external AI providers. Include checks for:

  • authenticated reads and writes, plus 401/403 failures;
  • read-only, write, destructive, and undeclared custom-action exposure;
  • direct tool-only fetches and generated route envelopes;
  • relationship-derived cache invalidation after a mutation;
  • mounted Form/DataSurface registration and consent behavior;
  • bespoke tool lifecycle, SSR, and a missing modelContext.

The executable fixture is packages/smrt-web/src/webmcp-e2e.integration.test.ts for database, REST, cache, and generated tools, and packages/smrt-svelte/src/web/__tests__/webmcp-composed.integration.svelte.test.ts for the mounted UI and bespoke component composition.