Skip to content
TutorialAI & LLMs

WebMCP: let AI agents use your app

Chrome 149 opens an origin trial for WebMCP, a proposed standard for pages to register tools that an AI agent in the browser can call. Here is the API as it ships, and a Vue composable that ties a tool to a component's lifetime.

vuejs.school team4 Jun 20266 min readChrome 149 origin trial · Vue 3.5

An AI agent that uses a website on your behalf usually works like a very patient stranger: it reads the page, guesses which button does what, and clicks. Chrome's docs call this actuation, and every guess is a chance to get it wrong.

WebMCP is a proposed web standard that lets the page say instead: here is a tool called add_todo, here is what it does, here is the input it takes. You can try it in Chrome 149's origin trial, and a small Vue composable is enough to offer a tool for exactly as long as the component that carries it out is on screen.

What a WebMCP tool is

A WebMCP tool has four parts:

  • a name
  • a plain-language description, which the agent reads to decide when to use the tool
  • an inputSchema written in JSON Schema, a standard format for describing the shape of JSON data
  • an execute function, which the browser calls with the agent's arguments

The function runs in your page, with your code, and changes your UI the way a click would. The user watches it happen.

WebMCP takes its name from the Model Context Protocol (MCP), which lets AI apps call tools on a server. WebMCP puts the tools in the page instead, so they reuse the app's own state and the user's existing session. The Chrome docs describe two ways to define them: an imperative API in JavaScript, and a declarative one made of attributes on a <form>.

The docs also list the limits. Tools need an open tab, so there is no headless use, and an agent only finds your tools by visiting the page.

Turn WebMCP on in Chrome 149

There are two ways to get the API in Chrome.

  • For local development, open chrome://flags/#enable-webmcp-testing, set it to Enabled and relaunch Chrome.
  • For a real site, register for the WebMCP origin trial. An origin trial is Chrome's way of letting sites try an unfinished feature with real users: you get a token for your origin and add it to your pages, for example with <meta http-equiv="origin-trial" content="YOUR_TOKEN"> in index.html. Chrome 149 reached the stable channel on 2 June, and the feature's Chrome Status entry names it as the first version in which the trial runs.

Either way, the API exists only on secure pages: HTTPS, or localhost while you develop.

Register a tool

In Chrome 149, the entry point is navigator.modelContext. The explainer and the draft specification have already moved it to document.modelContext, a change merged on 27 May, after Chrome 149 had branched on 4 May. Catching changes like this is what an origin trial is for, and it is why the composable further down checks both. A minimal registration looks like this:

const cart = { total: 32 }
const controller = new AbortController()

navigator.modelContext?.registerTool(
  {
    name: 'get_cart_total',
    description: 'Return the cart total in pounds as a number.',
    inputSchema: { type: 'object', properties: {} },
    annotations: { readOnlyHint: true },
    execute: async () => String(cart.total),
  },
  { signal: controller.signal },
)

// Later: remove the tool again.
controller.abort()

The second argument takes an AbortSignal. Aborting it unregisters the tool, which is the only way to remove one in this version.

The optional annotations object holds two hints:

  • readOnlyHint: true says the tool only reads and changes nothing, so an agent can call it without worrying about side effects.
  • untrustedContentHint: true says the result contains data you did not write yourself, such as user input or reviews, so the agent should treat it with suspicion.

registerTool() registers nothing and throws an error when:

  • a tool with the same name is already registered on the page
  • the name is empty, longer than 128 characters, or uses anything other than ASCII letters, digits, _, - and . (so add todo fails)
  • the description is empty
  • the input schema cannot be turned into JSON
  • the page is not allowed to register tools (the tools policy, covered below)

Tie a tool to a component's lifetime

TypeScript 6.0 does not know about modelContext, so declare the parts you use. Keep the declaration to the shape Chrome 149 implements:

src/webmcp.d.ts
export {}

declare global {
  interface WebMcpTool {
    name: string
    description: string
    inputSchema?: object
    annotations?: { readOnlyHint?: boolean; untrustedContentHint?: boolean }
    execute: (input: Record<string, unknown>) => unknown
  }

  interface ModelContext extends EventTarget {
    registerTool(tool: WebMcpTool, options?: { signal?: AbortSignal }): void
  }

  interface Navigator {
    readonly modelContext?: ModelContext
  }

  interface Document {
    readonly modelContext?: ModelContext
  }

  interface SubmitEvent {
    readonly agentInvoked?: boolean
    respondWith?(response: Promise<unknown>): void
  }
}

The properties added to Navigator, Document and SubmitEvent are optional on purpose: in most browsers they do not exist. The SubmitEvent lines are for the form example further down.

Now the composable. A tool should exist exactly as long as the component that can carry it out, so it registers in onMounted and aborts in onBeforeUnmount:

src/composables/useWebMcpTool.ts
import { onBeforeUnmount, onMounted, ref } from 'vue'

export function useWebMcpTool(tool: WebMcpTool) {
  const registered = ref(false)
  let controller: AbortController | undefined

  onMounted(() => {
    const modelContext = document.modelContext ?? navigator.modelContext
    if (!modelContext) return

    controller = new AbortController()
    try {
      modelContext.registerTool(tool, { signal: controller.signal })
      registered.value = true
    } catch (error) {
      console.warn(`WebMCP: could not register "${tool.name}"`, error)
    }
  })

  onBeforeUnmount(() => {
    controller?.abort()
    registered.value = false
  })

  return { registered }
}

The feature check lives inside onMounted, which only runs in the browser, so the composable is safe in a server-rendered app too. The try matters: a duplicate name throws, and a failed registration should never break the component. Because names are unique, use this for components that appear once on a page.

Give a to-do list two tools

Here the agent gets two tools: add_todo calls the same function as the form, and list_todos reads the list.

src/components/TodoList.vue
<script setup lang="ts">
import { ref } from 'vue'
import { useWebMcpTool } from '../composables/useWebMcpTool'

type Todo = { id: number; text: string; done: boolean }

const todos = ref<Todo[]>([])
const draft = ref('')
let nextId = 1

function addTodo(text: string) {
  todos.value.push({ id: nextId++, text, done: false })
}

function onSubmit() {
  if (draft.value.trim()) addTodo(draft.value.trim())
  draft.value = ''
}

const { registered } = useWebMcpTool({
  name: 'add_todo',
  description: 'Add one item to the to-do list on this page. Use it once per item.',
  inputSchema: {
    type: 'object',
    properties: {
      text: { type: 'string', description: 'What needs doing, in a few words' },
    },
    required: ['text'],
  },
  execute: async ({ text }) => {
    if (typeof text !== 'string' || !text.trim()) {
      return 'Nothing added: "text" must be a non-empty string.'
    }
    addTodo(text.trim())
    return `Added "${text.trim()}". The list now has ${todos.value.length} items.`
  },
})

useWebMcpTool({
  name: 'list_todos',
  description: 'Return the to-do items on this page as JSON, with id, text and done.',
  inputSchema: { type: 'object', properties: {} },
  annotations: { readOnlyHint: true, untrustedContentHint: true },
  execute: async () => JSON.stringify(todos.value),
})
</script>

<template>
  <form @submit.prevent="onSubmit">
    <label for="todo-text">New to-do</label>
    <input id="todo-text" v-model="draft" required>
    <button type="submit">Add</button>
  </form>
  <ul>
    <li v-for="todo in todos" :key="todo.id">
      <label><input v-model="todo.done" type="checkbox"> {{ todo.text }}</label>
    </li>
  </ul>
  <p v-if="registered">An AI agent in your browser can use this list too.</p>
</template>

Three choices are worth copying. execute calls addTodo, the function the form uses, so there is one code path and the new item appears on screen like any other. The tool checks its input even though the schema says text is required: the schema guides the agent, it does not guarantee what arrives. And list_todos is marked read-only and untrusted, because the items are text a person typed, and text can contain instructions aimed at the agent.

The return value is a short sentence the agent can read. Say what happened, and say clearly when nothing did.

Make a form a tool with attributes

If the action is already a form, the declarative API may be all you need: no registration code. Chrome 149 understands four attributes: toolname and tooldescription on the <form>, toolparamdescription on each field, and the boolean toolautosubmit. The field's name becomes the property name in the generated schema.

src/components/AddTodoForm.vue
<script setup lang="ts">
import { ref } from 'vue'

const emit = defineEmits<{ add: [text: string] }>()
const draft = ref('')

function onSubmit(event: SubmitEvent) {
  const text = draft.value.trim()
  if (text) emit('add', text)
  draft.value = ''

  if (event.agentInvoked) {
    const answer = text ? `Added "${text}".` : 'Nothing added: the text was empty.'
    event.respondWith?.(Promise.resolve(answer))
  }
}
</script>

<template>
  <form
    toolname="add_todo"
    tooldescription="Add one item to the to-do list on this page."
    @submit.prevent="onSubmit"
  >
    <label for="todo-text">New to-do</label>
    <input
      id="todo-text"
      v-model="draft"
      name="text"
      toolparamdescription="What needs doing, in a few words"
      required
    >
    <button type="submit">Add</button>
  </form>
</template>

Vue passes unknown attributes on native elements straight through, so no plugin is needed. Without toolautosubmit, the agent fills the field and the browser moves focus to the submit button, so the person checks and submits. That is the right default for anything with consequences. Chrome fires the usual input event when it fills the field, so v-model sees the agent's text.

The agent is still waiting when the person presses Add. On that submit, Chrome 149 sets event.agentInvoked to true. Because .prevent stops the normal submission, Chrome expects the answer through event.respondWith(), which takes a promise of the result. Without that call, the agent is told the tool failed even though the item was added. Clear the field through draft, not form.reset(): in Chrome 149 a reset cancels the agent's call.

This form and the add_todo tool in TodoList.vue are two ways to offer the same action. Pick one style per action, and keep tool names unique across both.

Where a page may register tools

Both APIs sit behind a Permissions Policy called tools. Its default is self: top-level pages and same-origin iframes may register tools, and cross-origin iframes may not unless the parent adds allow="tools" to the <iframe>.

Sending Permissions-Policy: tools=() turns registration off entirely. registerTool() then throws a SecurityError in Chrome 149 (the draft specification names NotAllowedError), and forms marked as tools are skipped without an error.

Test your tools with the inspector extension

To see your tools from the agent's side, the Chrome docs point to the Model Context Tool Inspector extension. It lists the tools registered on the current page, lets you call one by hand with your own arguments, and shows what it returns. It can also chat with a Gemini model that picks tools for you, which shows whether your descriptions are clear enough. Test the unhappy paths too: a missing argument, an empty string, and a tool that should vanish from the list when you navigate away from its component.

Check it stuck3 questions from the WebMCP: let agents use your app quiz.
Practice now
Was this helpful?

Keep reading