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: 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.
"use client";
import React, {
useCallback,
useMemo,
useRef,
useState,
StrictMode,
} from "react";
import { createRoot } from "react-dom/client";
import { AgStudio, AgStudioProvider, AgStudioRef } from "ag-studio-react";
import {
AgDataEngine,
AgDataSourcesDefinition,
AgReportState,
AgStudioAiModule,
AgStudioApi,
AgStudioMode,
AgStudioProperties,
enableStudioDevValidations,
} from "ag-studio";
import { getGhcnCitiesData } from "./shared/ghcnCities/data.tsx";
import { ghcnCitiesReportState } from "./shared/ghcnCities/state.tsx";
import { PageUpdater } from "./interfaces.tsx";
import { StudioWebMcpBridge, createWebMcpBridge } from "./webmcpBridge.tsx";
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(" ");
const updateModeButton: (mode: string) => void = (mode: string) => {
document.querySelector<HTMLElement>("#modeButton")!.textContent =
mode === "edit" ? "Switch to view mode" : "Switch to edit mode";
};
const reconcileAndRender: () => Promise<void> = async () => {
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.
*/
const renderSupportNotice: () => void = () => {
if (document.modelContext != null) {
return;
}
document.querySelector<HTMLElement>("#webmcpNotice")!.textContent =
NOTICE_TEXT;
document.querySelector<HTMLButtonElement>("#callToolButton")!.disabled = true;
};
const 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);
}
};
const renderLiveTools: () => Promise<void> = async () => {
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 StudioExample = () => {
const studioRef = useRef<AgStudioRef>(null);
const containerStyle = useMemo(() => ({ width: "100%", height: "100%" }), []);
const studioStyle = useMemo(() => ({ height: "100%", width: "100%" }), []);
const [data, setData] = useState<AgDataSource>(
getGhcnCitiesData("https://www.ag-grid.com/studio/example-assets"),
);
const initialState = useMemo<AgReportState>(() => {
return ghcnCitiesReportState;
}, []);
const onStudioReady = useCallback((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();
}, []);
const onStateUpdated = useCallback(() => {
void reconcileAndRender();
}, []);
const onRenderStateChanged = useCallback(() => {
void reconcileAndRender();
}, []);
const onStudioPreDestroyed = useCallback(() => {
bridge?.destroy();
}, []);
const toggleMode = useCallback((): void => {
const mode =
studioRef.current!.api.getProperty("mode") === "edit" ? "view" : "edit";
studioRef.current!.api.setProperty("mode", mode);
updateModeButton(mode);
}, []);
const callTool = useCallback(async (): Promise<void> => {
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)}`;
}
}, []);
// `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 = useCallback((state: AgReportState): void => {
studioRef.current!.api.setState(state);
void reconcileAndRender();
}, []);
const setPage = useCallback(
(pageId: string): void => {
applyState({
...studioRef.current!.api.getState(),
selectedPageId: pageId,
});
},
[applyState],
);
const addPage = useCallback((): void => {
const state = studioRef.current!.api.getState();
if (state.pages.some((page) => page.id === "secondary")) return;
applyState({ ...state, pages: [...state.pages, { id: "secondary" }] });
}, [applyState]);
const removePage = useCallback((): 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 = studioRef.current!.api.getState();
applyState({
...state,
pages: state.pages.filter((page) => page.id !== "secondary"),
selectedPageId: "temperature",
});
}, [applyState]);
const addCalculatedField = useCallback((): void => {
applyState({
...studioRef.current!.api.getState(),
schema: {
expressions: [
{ isMeasure: false, id: "temp_range", tableId: "weather" },
],
fields: {
temp_range: {
name: "Temp Range",
expression: "[weather.tmax] - [weather.tmin]",
},
},
},
});
}, [applyState]);
const removeCalculatedField = useCallback((): void => {
applyState({ ...studioRef.current!.api.getState(), schema: {} });
}, [applyState]);
const updateSelectedPage = useCallback(
(update: PageUpdater): void => {
const state = studioRef.current!.api.getState();
applyState({
...state,
pages: state.pages.map((page) =>
page.id === state.selectedPageId ? update(page) : page,
),
});
},
[applyState],
);
const removeWidgets = useCallback((): void => {
updateSelectedPage((page) => ({ ...page, widgets: {}, widgetLayout: {} }));
}, [updateSelectedPage]);
const restoreWidgets = useCallback((): void => {
updateSelectedPage((page) => {
const original = populatedPages.find(
(candidate) => candidate.id === page.id,
);
return original
? {
...page,
widgets: original.widgets,
widgetLayout: original.widgetLayout,
}
: page;
});
}, [updateSelectedPage, populatedPages]);
return (
<div style={containerStyle}>
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<div className="example-controls">
<div className="controls-row">
<button type="button" onClick={() => setPage("temperature")}>
Temperature
</button>
<button type="button" onClick={() => setPage("precipitation")}>
Precipitation
</button>
<button type="button" onClick={() => setPage("blank")}>
Blank
</button>
</div>
<div className="controls-row">
<button type="button" id="modeButton" onClick={toggleMode}>
Switch to view mode
</button>
<button type="button" onClick={removeWidgets}>
Remove widgets
</button>
<button type="button" onClick={restoreWidgets}>
Restore widgets
</button>
<button type="button" onClick={addPage}>
Add page
</button>
<button type="button" onClick={removePage}>
Remove page
</button>
<button type="button" onClick={addCalculatedField}>
Add calculated field
</button>
<button type="button" onClick={removeCalculatedField}>
Remove calculated field
</button>
<button type="button" id="callToolButton" onClick={callTool}>
Call a tool
</button>
</div>
</div>
<div className="webmcp-panel">
<div className="webmcp-notice" id="webmcpNotice"></div>
<div className="webmcp-error" id="webmcpError"></div>
<div id="advertisedTools"></div>
<div id="liveTools"></div>
<div className="webmcp-output" id="toolOutput"></div>
</div>
<AgStudio
ref={studioRef}
style={studioStyle}
className="my-studio-container"
data={data}
mode={"edit"}
initialState={initialState}
onStudioReady={onStudioReady}
onStateUpdated={onStateUpdated}
onRenderStateChanged={onRenderStateChanged}
onStudioPreDestroyed={onStudioPreDestroyed}
/>
</div>
</div>
);
};
const root = createRoot(document.getElementById("root")!);
root.render(
<StrictMode>
<AgStudioProvider modules={[AgStudioAiModule]}>
<StudioExample />
</AgStudioProvider>
</StrictMode>,
);
The Read-Only Slice Copy Link
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 Copy Link
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()returnsundefined- 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 Copy Link
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 Copy Link
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.
Availability Copy Link
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 Copy Link
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 Copy Link
- Tools Overview - the tools this page publishes, and running them yourself
- Custom Tools - authoring a tool of your own to publish
- Agent Context - describing the data to an agent