s-m-r-t
Open the s-m-r-t source on GitHub Switch to dark color scheme
← Capabilities

New capability

Your web app becomes an agent tool surface

s-m-r-t generates WebMCP tools from the model actions and field metadata that MCP uses. It registers the tools with the browser. In-page agents can then discover and invoke the tools.

Verified against s-m-r-t 0.44.0

Page tools
ManifestJSON SchemaWebMCPREST policy
Browser agents discover model actions that still use the signed-in server session.

One tool vocabulary, in the server and the page

The core descriptor builder emits the same stable tool names, descriptions, and JSON Schema inputs for Node MCP and browser WebMCP. A product_list tool means the same thing on both surfaces; list and get are marked with the WebMCP read-only hint.

  • One generated descriptor per exposed collection action.
  • CRUD tools cover list, get by ID or slug, create, update, and delete.
  • Unsupported browsers and server rendering safely receive a no-op disposer.

Register only the tools this page should offer

Pass the generated web tool definitions to registerWebMcpTools. Omitting an exposure policy is deliberate: it registers read-only tools only. The returned disposer removes every registration together.

webmcp.client.ts
typescript
import { webMcpToolDefinitions } from '@happyvertical/smrt-virt-web';
import { registerWebMcpTools } from '@happyvertical/smrt-web';

// Omitting an exposure policy registers read-only tools only.
const dispose = registerWebMcpTools(webMcpToolDefinitions);

// Remove this page's tools when the surface unmounts.
dispose();

Opt into more than read-only

Name the effects a page may register, prefix its tool names to avoid collisions between two Providers on one page, and cap how many tools one call may register. No implicit budget applies to a whole-manifest read registration.

webmcp.client.ts
typescript
registerWebMcpTools(webMcpToolDefinitions, {
  effects: ['read', 'write', 'destructive'],
  namespace: 'admin',
  maxTools: 32
});

A component's own tool follows the same policy

useWebMcpTool registers one hand-written tool for a component's lifetime and routes it through smrt-web's registerWebMcpBespokeTool, so the same fail-closed classification filters it. A spec with no annotations, or whose annotations leave its effect undeclared, classifies destructive, non-idempotent, and open-world. It is excluded unless the policy allows destructive. The policy is the nearest Provider's webmcp.effects. The hook's own effects option applies only when no Provider ancestor declares a policy. An explicit Provider policy always wins, even a narrower one.

  • namespace and maxTools never apply to a bespoke tool. The author already chose a stable name, and one tool never counts against a generated set's budget.
  • The generated Form webmcp tool declares itself write-class, never destructive. It falls back to a read and write policy only when no ancestor Provider declares webmcp.effects.
  • On a browser without WebMCP, or when policy excludes the tool's effect, the call is a no-op and its disposer is inert.
OrderTable.svelte
svelte
<script lang="ts">
  import { useWebMcpTool } from '@happyvertical/smrt-svelte';

  let { rows } = $props();

  // readOnlyHint declares a read effect. Leave annotations out and the
  // tool classifies destructive and is excluded under the read-only default.
  useWebMcpTool(() => ({
    name: 'orders_visible_count',
    description: 'How many orders the table currently shows',
    inputSchema: { type: 'object', properties: {} },
    annotations: { readOnlyHint: true },
    execute: async () => String(rows.length)
  }));
</script>

Declare an interaction as data

defineIntent, from the dependency-free @happyvertical/smrt-web/intents entry, declares a component-owned interaction in a .ts sidecar as plain data. The declaration names an id, a description, an optional capability, and a target: one browser registry — control or dataSurface — and what the intent does there. Call it at module scope with one object literal of literal values: no spreads, identifiers, conditionals, or computed keys. That static form is what a scanner reads without evaluating the module; anything computed keeps using useWebMcpTool.

  • Ids are lowercase and dot-namespaced with at least two segments. The WebMCP tool name is the id with . and - replaced by _. An id resolving into the reserved smrt_ui_ prefix is rejected. So is a second id that derives a tool name an already declared intent derives.
  • capability resolves through the same fail-closed rule as every other declaration: omitted entirely, an intent classifies destructive, non-idempotent, and open-world.
  • The no-REST invariant: the declaration has no execute, fetch, url, route, endpoint, or method field and no field of function type. defineIntent rejects unknown keys and non-JSON values at runtime, and the tool's execute is constructed from target — no author-supplied callable ever runs.
OrderTable.intents.ts
typescript
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 what is mounted

useViewIntent binds a declared intent to this component's mounted registry identity for exactly the component's lifetime. The identity is formId and controlId for a control intent, or surfaceId and kind for a data-surface one, with an optional subject for record-qualified identity. Registries come from the nearest Provider's WebMCP UI context or an explicit controlRegistry or dataSurfaceRegistry override; with neither, the binding is a silent no-op. Registration goes through registerWebMcpBespokeTool, so the intent inherits the policy above. Execution dispatches exactly one registry command as source: 'agent'. Staged review, the trusted local-gesture requirement, sensitivity, and writability apply unchanged, and there is no path to REST.

  • An identity whose shape does not match the intent's declared target, or that contradicts an identity the declaration pinned, throws. That is an author error, not an environment difference.
  • The compiler is imported from the /intents entry, so binding an intent never pulls the client-data engine into the page.
  • The six fixed smrt_ui_ tools are unchanged; intents sit above them. A derived name can still collide with a generated model tool or, under a custom UI prefix, with a fixed UI tool. Keep an intent's first id segment out of your model names, or give the generated set a namespace.
OrderTable.svelte
svelte
<script lang="ts">
  import { useViewIntent } from '@happyvertical/smrt-svelte';
  import { nextPageIntent } from './OrderTable.intents';

  // The identity shape must match the declared target registry:
  // { surfaceId, kind } for a data-surface intent.
  useViewIntent(nextPageIntent, {
    identity: { surfaceId: 'orders', kind: 'table' }
  });
</script>

The page session is the security boundary

A generated model tool executes over the generated REST fetchers as the authenticated page user. A declared interaction has no path to REST at all, and a component tool runs only the code its author wrote. If that code calls the application's routes, the same server checks apply; if it does not, there is no server round-trip to fail closed. REST authentication, tenant gates, writable-field rules, sensitive-field policy, and operation permissions are enforced in their existing server boundary rather than copied into browser tool code. The exposure policy above is a capability choice, not an authorization boundary.