---
product: "AG Studio"
title: "WebMCP"
description: "Learn how to publish AG Studio's read-only tools to a browser AI agent through the WebMCP API, and keep the advertised set in step with live dashboard state."
framework: vue
version: "3.0.0"
related:
    - title: "Agent Framework Overview"
      url: "https://www.ag-grid.com/studio/vue/ai/"
    - title: "Agent Quick Start"
      url: "https://www.ag-grid.com/studio/vue/ai-quickstart/"
llms: "https://www.ag-grid.com/studio/llms.txt"
---

# WebMCP

> **Warning**
>
> WebMCP is experimental. `document.modelContext` ships in Chrome only, behind an origin trial, and is not part of AG Studio's supported browser matrix. Treat this page as a pattern to build on, not a stable integration.

WebMCP lets a page publish its own capabilities as structured tools, so an AI agent running in the browser can call them directly.

`api.getAiTools()` returns Studio's built-in tools as live instances. Each one reads dashboard state when it executes, and none of them needs an AI harness, so they can be exposed to WebMCP as they are.

The tools do need the AI module registered, `AgStudioModuleRegistry.registerModules([AgStudioAiModule])`, because they read the field schema and dashboard context the module provides. Without it every tool still registers and runs, but returns an empty schema.

This is the clearest case of a Studio integration with [no harness](https://www.ag-grid.com/studio/vue/ai-tools/#without-a-harness): the agent is the browser's, the UI is the browser's, and Studio contributes tools.

The example includes a small bridge, `webmcpBridge.ts`, which maps one Studio tool to one `document.modelContext.registerTool` call and keeps the registrations matching live state.

#### WebMCP

```ts
import {
  createApp,
  defineComponent,
  onBeforeMount,
  ref,
  shallowRef,
} from "vue";
import { AgStudio } from "ag-studio-vue3";
import {
  AgDataEngine,
  AgDataSourcesDefinition,
  AgReportState,
  AgStudioAiModule,
  AgStudioApi,
  AgStudioApiReadyEvent,
  AgStudioMode,
  AgStudioModuleRegistry,
  AgStudioProperties,
  enableStudioDevValidations,
} from "ag-studio";
import { getGhcnCitiesData } from "./shared/ghcnCities/data.ts";
import { ghcnCitiesReportState } from "./shared/ghcnCities/state.ts";
import { PageUpdater } from "./interfaces.ts";
import { StudioWebMcpBridge } from "./webmcpBridge.ts";
import { createWebMcpBridge } from "./webmcpBridge.ts";

AgStudioModuleRegistry.registerModules([AgStudioAiModule]);
if (process.env.NODE_ENV !== "production") {
  // Enable extended validations only for development
  enableStudioDevValidations();
}

// `view_widget` is the only one of the four tools that can go uncallable: its widget-id enum comes
// from the *selected* page's widgets, so it is withheld whenever that page has none. Two controls
// show this - clearing the current page, and the page toolbar's blank page, which starts empty.
const populatedPages = ghcnCitiesReportState.pages;

let bridge: StudioWebMcpBridge | undefined;

let reconcileCount = 0;

const NOTICE_TEXT = [
  "This browser does not expose document.modelContext, so nothing is registered with the browser.",
  "WebMCP is experimental: Chrome 149+ behind an origin trial or chrome://flags/#enable-webmcp-testing,",
  "and it requires a secure context. The list below is what would be advertised.",
].join(" ");

function updateModeButton(mode: string): void {
  document.querySelector<HTMLElement>("#modeButton")!.textContent =
    mode === "edit" ? "Switch to view mode" : "Switch to edit mode";
}

async function reconcileAndRender(): Promise<void> {
  const errorEl = document.querySelector<HTMLElement>("#webmcpError")!;
  try {
    await bridge?.reconcile();
    renderPanel();
    errorEl.textContent = bridge?.lastError() ?? "";
  } catch (err) {
    errorEl.textContent = `Reconcile failed: ${err instanceof Error ? err.message : String(err)}`;
  }
}

/**
 * The notice and the call button belong to the panel, not to start-up: a framework renders its
 * template after mount, so anything written at mount time has nothing to write into yet.
 */
function renderSupportNotice(): void {
  if (document.modelContext != null) {
    return;
  }
  document.querySelector<HTMLElement>("#webmcpNotice")!.textContent =
    NOTICE_TEXT;
  document.querySelector<HTMLButtonElement>("#callToolButton")!.disabled = true;
}

function renderPanel(): void {
  renderSupportNotice();
  const container = document.querySelector<HTMLElement>("#advertisedTools")!;
  container.dataset.reconciles = String(++reconcileCount);
  container.replaceChildren();
  for (const { name, registrations } of bridge?.getAdvertisedTools() ?? []) {
    const item = document.createElement("div");
    item.className = "advertised-tool";
    item.dataset.tool = name;
    item.dataset.registrations = String(registrations);
    item.textContent = `${name} (registrations: ${registrations})`;
    container.appendChild(item);
  }
}

async function renderLiveTools(): Promise<void> {
  const live = document.querySelector<HTMLElement>("#liveTools")!;
  const { modelContext } = document;
  if (modelContext == null) return;
  try {
    const advertised = await modelContext.getTools();
    live.textContent = `Registered with the browser: ${advertised.map((tool) => tool.name).join(", ")}`;
  } catch (err) {
    document.querySelector<HTMLElement>("#webmcpError")!.textContent =
      `Could not read the registered tools: ${err instanceof Error ? err.message : String(err)}`;
  }
}

const VueExample = defineComponent({
  template: `
        <div style="height: 100%">
                <div style="display: flex; flex-direction: column; height: 100%">
      <div class="example-controls">
        <div class="controls-row">
          <button type="button" v-on:click="setPage('temperature')">Temperature</button>
          <button type="button" v-on:click="setPage('precipitation')">Precipitation</button>
          <button type="button" v-on:click="setPage('blank')">Blank</button>
        </div>
        <div class="controls-row">
          <button type="button" id="modeButton" v-on:click="toggleMode()">Switch to view mode</button>
          <button type="button" v-on:click="removeWidgets()">Remove widgets</button>
          <button type="button" v-on:click="restoreWidgets()">Restore widgets</button>
          <button type="button" v-on:click="addPage()">Add page</button>
          <button type="button" v-on:click="removePage()">Remove page</button>
          <button type="button" v-on:click="addCalculatedField()">Add calculated field</button>
          <button type="button" v-on:click="removeCalculatedField()">Remove calculated field</button>
          <button type="button" id="callToolButton" v-on:click="callTool()">Call a tool</button>
        </div>
      </div>
      <div class="webmcp-panel">
        <div class="webmcp-notice" id="webmcpNotice"></div>
        <div class="webmcp-error" id="webmcpError"></div>
        <div id="advertisedTools"></div>
        <div id="liveTools"></div>
        <div class="webmcp-output" id="toolOutput"></div>
      </div>
      <ag-studio
        style="width: 100%; height: 100%;"
        class="my-studio-container"
        @api-ready="onApiReady"
        :mode="mode"
        :initialState="initialState"
        :data="data"
        @studio-ready="onStudioReady"
        @state-updated="onStateUpdated"
        @render-state-changed="onRenderStateChanged"
        @studio-pre-destroyed="onStudioPreDestroyed"></ag-studio>
      </div>
        </div>
    `,
  components: {
    "ag-studio": AgStudio,
  },
  setup(props) {
    const studioApi = shallowRef<AgStudioApi | null>(null);
    const mode = ref<AgStudioMode>("edit");
    const initialState = ref<AgReportState>(ghcnCitiesReportState);
    const data = ref<AgDataSourcesDefinition | AgDataEngine>(
      getGhcnCitiesData("https://www.ag-grid.com/studio/example-assets"),
    );

    function onStudioReady(event) {
      const studio = event.api.getAiTools();
      bridge = createWebMcpBridge([
        { tool: studio.viewSchema(), readOnly: true },
        { tool: studio.viewPage(), readOnly: true },
        { tool: studio.viewWidget(), readOnly: true },
        { tool: studio.executeQuery(), readOnly: true },
      ]);
      void reconcileAndRender();
    }
    function onStateUpdated() {
      void reconcileAndRender();
    }
    function onRenderStateChanged() {
      void reconcileAndRender();
    }
    function onStudioPreDestroyed() {
      bridge?.destroy();
    }
    const toggleMode: () => void = () => {
      const mode =
        studioApi.value.getProperty("mode") === "edit" ? "view" : "edit";
      studioApi.value.setProperty("mode", mode);
      updateModeButton(mode);
    };
    const removeWidgets: () => void = () => {
      updateSelectedPage((page) => ({
        ...page,
        widgets: {},
        widgetLayout: {},
      }));
    };
    const restoreWidgets: () => void = () => {
      updateSelectedPage((page) => {
        const original = populatedPages.find(
          (candidate) => candidate.id === page.id,
        );
        return original
          ? {
              ...page,
              widgets: original.widgets,
              widgetLayout: original.widgetLayout,
            }
          : page;
      });
    };
    const setPage: (pageId: string) => void = (pageId: string) => {
      applyState({ ...studioApi.value.getState(), selectedPageId: pageId });
    };
    const addPage: () => void = () => {
      const state = studioApi.value.getState();
      if (state.pages.some((page) => page.id === "secondary")) return;
      applyState({ ...state, pages: [...state.pages, { id: "secondary" }] });
    };
    const removePage: () => void = () => {
      // Never remove the last page: with no pages `view_widget` goes uncallable, which would collide
      // the page scenario with the widget scenario.
      const state = studioApi.value.getState();
      applyState({
        ...state,
        pages: state.pages.filter((page) => page.id !== "secondary"),
        selectedPageId: "temperature",
      });
    };
    const addCalculatedField: () => void = () => {
      applyState({
        ...studioApi.value.getState(),
        schema: {
          expressions: [
            { isMeasure: false, id: "temp_range", tableId: "weather" },
          ],
          fields: {
            temp_range: {
              name: "Temp Range",
              expression: "[weather.tmax] - [weather.tmin]",
            },
          },
        },
      });
    };
    const removeCalculatedField: () => void = () => {
      applyState({ ...studioApi.value.getState(), schema: {} });
    };
    const callTool: () => Promise<void> = async () => {
      const output = document.querySelector<HTMLElement>("#toolOutput")!;
      const { modelContext } = document;
      if (modelContext == null) return;
      try {
        const advertised = await modelContext.getTools();
        const tool = advertised.find(
          (candidate) => candidate.name === "view_schema",
        );
        if (tool == null) {
          output.textContent = "view_schema is not currently advertised.";
          return;
        }
        // `view_schema` takes no parameters at all, so an empty object is a complete call.
        const result = await modelContext.executeTool(tool, JSON.stringify({}));
        // The browser hands back the tool's `content` array; older builds returned a bare string.
        output.textContent =
          typeof result === "string"
            ? result
            : result.content.map((part) => part.text).join("\n");
      } catch (err) {
        output.textContent = `Tool call failed: ${err instanceof Error ? err.message : String(err)}`;
      }
    };
    const onApiReady = (params: AgStudioApiReadyEvent) => {
      studioApi.value = params.api;
    };
    // `api.getState()` hands back Studio's live state object, and `api.setState()` ignores a call whose
    // argument is that same reference. Every control below therefore builds a new state object, and a
    // new object for the slice it changes - Studio also compares each state slice by reference.
    const applyState: (state: AgReportState) => void = (
      state: AgReportState,
    ) => {
      studioApi.value.setState(state);
      void reconcileAndRender();
    };
    const updateSelectedPage: (update: PageUpdater) => void = (
      update: PageUpdater,
    ) => {
      const state = studioApi.value.getState();
      applyState({
        ...state,
        pages: state.pages.map((page) =>
          page.id === state.selectedPageId ? update(page) : page,
        ),
      });
    };

    return {
      studioApi,
      mode,
      initialState,
      data,
      onApiReady,
      onStudioReady,
      onStateUpdated,
      onRenderStateChanged,
      onStudioPreDestroyed,
      toggleMode,
      removeWidgets,
      restoreWidgets,
      setPage,
      addPage,
      removePage,
      addCalculatedField,
      removeCalculatedField,
      callTool,
      applyState,
      updateSelectedPage,
    };
  },
});

const app = createApp(VueExample);
app.mount("#app");
```

[Live example: WebMCP](https://www.ag-grid.com/studio/examples/ai-webmcp/ai-webmcp-example/vue3/)

## The Read-Only Slice

`api.getAiTools()` returns fifteen tools, including a per-widget `configureWidget` factory and tools that only make sense inside Studio's own harness. **The example publishes four: `view_schema`, `view_page`, `view_widget`, and `execute_query`.**

Each is passed to the bridge as `{ tool, readOnly: true }`, which the bridge turns into `annotations.readOnlyHint`. The caller declares that per tool, because a browser agent may relax its confirmation policy on a tool the page annotates as read-only.

Registering the mutating tools instead would hand an arbitrary external agent write access to the dashboard, and would push multi-step orchestration onto a client that has none of Studio's system prompts. Four read-only tools stay small, need no AI harness, and still exercise every branch of the reconcile pattern below.

| Tool | Parameters | Behaviour |
| --- | --- | --- |
| `view_schema` | None | Static schema. Always callable. |
| `view_page` | None | Reports the active page. Always callable. |
| `view_widget` | Live enums of page and widget ids | Uncallable while the dashboard has no widgets or no pages. |
| `execute_query` | Query shape derived from the loaded field schema | Always callable. Its schema content changes with the field schema. |

`view_widget` is the only one of the four that can become uncallable. `view_page` carries no page id, and `execute_query` falls back to a non-empty field list, so neither ever drops out of the advertised set.

## The Reconcile Pattern

A Studio tool's schema is derived from live state. `tool.schema()` re-reads that state on every call and returns `undefined` while the tool is uncallable. A WebMCP registration is the opposite: once `registerTool` resolves, the descriptor is fixed until its `AbortSignal` fires. Bridging the two takes a reconcile step.

The bridge keeps one `AbortController` per registered tool, plus a signature of the serialised `inputSchema`. On each pass, for every tool:

- `schema()` returns `undefined` - abort the controller and drop the entry, so the tool is no longer advertised.
- The signature matches the stored one - do nothing, so calling `reconcile()` more often than needed costs nothing.
- The signature differs - abort the old controller, then register a fresh descriptor.
- There is no entry - register.

A rejected `registerTool` drops the entry again, so the next pass retries instead of skipping a tool the browser never accepted. `lastError()` carries the reason until the following pass.

`reconcile()` is async and serialised. `registerTool` returns a promise, and Chrome does not specify what happens when a name is re-registered while its unregistration is still pending, so the bridge chains each abort and register rather than firing both in one tick.

### What Drives a Reconcile

The example calls `reconcile()` from `onStudioReady`, `onStateUpdated` and `onRenderStateChanged`, and calls `destroy()` from `onStudioPreDestroyed`. `destroy()` aborts every controller and also stops any pass still waiting on `registerTool`, which would otherwise register a tool against a destroyed Studio instance.

Those events do not cover every state change.

**A clean `api.setState()` raises no `stateUpdated` event.** Page and widget changes an application drives through `setState` are invisible to the public event surface, so the example calls `reconcile()` explicitly after each of its own `setState` calls.

**Replacing the reactive `data` property swaps the rows but does not re-derive the field schema.** `execute_query` keeps enumerating the previous source's field ids, and `reconcile()` cannot correct it, because the schema signature has not changed. The route to a new field set is `api.destroy()` followed by a fresh `createStudio()`, which rebuilds the bridge from a new `api.getAiTools()`.

Adding or removing a calculated field does change the field schema, and it dispatches `stateUpdated`, so it reconciles on its own. The example uses that control to show `execute_query` re-registering, and the widget controls to show `view_widget` leaving and re-entering the advertised set.

## Tool Parameters

A Studio tool advertises exactly the parameters its own action needs. A command-backed tool exposes its command's schema verbatim, with no status or envelope parameters wrapped around it, and `view_schema` takes none at all.

The bridge advertises the schema's `parameters` as they come. The one exception is `execute_query`, whose query shape is a union of the aggregation and projection forms. The bridge nests that under a `query` key so the root stays an object, because several LLM providers reject a tool whose parameters root is `anyOf` - see [JSON Schema Support](https://www.ag-grid.com/studio/vue/ai-custom-tools/#json-schema-support).

## Availability

`document.modelContext` requires Chrome 149 or later, a secure context, and the origin trial enabled. For local development, turn on `chrome://flags/#enable-webmcp-testing`. The API is gated by the `tools` permissions policy, which defaults to `self`, so a cross-origin iframe needs `allow="tools"`.

`navigator.modelContext` is the deprecated spelling of the same API. Use `document.modelContext`.

The example feature-detects `document.modelContext`. When it is missing, the example renders a notice, disables the button that calls a tool, and keeps running the reconcile bookkeeping, so the panel still shows which tools would be advertised in any browser.

## Extending to the Mutating Tools

The same bridge handles the mutating tools without change. They are `AgAiTool` instances with the same `schema()` and `execute()` shape. What changes is the risk.

Pass them as `{ tool, readOnly: false }` so the bridge does not claim `readOnlyHint` for a tool that writes, and use `exposedTo` on the register options to limit which agents can reach a tool. Authenticating and permissioning the WebMCP surface is out of scope for the example, and is yours to design.

## Next

- [Tools Overview](https://www.ag-grid.com/studio/vue/ai-tools/) - the tools this page publishes, and running them yourself
- [Custom Tools](https://www.ag-grid.com/studio/vue/ai-custom-tools/) - authoring a tool of your own to publish
- [Agent Context](https://www.ag-grid.com/studio/vue/ai-context/) - describing the data to an agent
