Skip to content

Commit fba7f19

Browse files
authored
Proxy WebMCP tools registered inside the WordPress iframe (#4301)
1 parent 7951f81 commit fba7f19

12 files changed

Lines changed: 1222 additions & 6 deletions

File tree

‎packages/playground/mcp/README.md‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,83 @@ The [Playground Website](https://playground.wordpress.net/) also supports [WebMC
8686

8787
> **Note:** WebMCP is still a draft proposal and not widely supported.
8888
89+
### Tools registered by the WordPress site
90+
91+
A plugin running inside Playground can register its own WebMCP tools, and both the Playground website and Personal Playground re-advertise them as tools of the page:
92+
93+
```php
94+
add_action( 'wp_head', function () {
95+
?>
96+
<script>
97+
document.modelContext.registerTool( {
98+
name: 'create_order',
99+
description: 'Creates a draft order.',
100+
inputSchema: { type: 'object', properties: { sku: { type: 'string' } } },
101+
execute: async ( input ) => ( { orderId: await createOrder( input.sku ) } ),
102+
} );
103+
</script>
104+
<?php
105+
} );
106+
```
107+
108+
WordPress runs in a nested iframe an agent never sees, so Playground proxies these tools:
109+
110+
```text
111+
agent → document.modelContext (Playground page)
112+
→ PlaygroundClient.callWebMCPTool() (Comlink)
113+
→ remote frame (postMessage)
114+
→ WordPress document (the plugin's execute())
115+
```
116+
117+
Tool names, descriptions and input schemas are carried over unchanged, and results must be JSON-serializable. A tool whose name collides with a built-in `playground_*` tool is skipped and a warning is logged.
118+
119+
### Tool lifetime
120+
121+
Tools belong to the document that registered them, exactly as in WebMCP, and the page shows what the iframe currently holds. Registering at any time works — a tool added a second after load, or from a click handler, is picked up as soon as it appears — but **every navigation starts from an empty list**.
122+
123+
That catches people out: hooking only `wp_head` means the tools are gone the moment the user opens wp-admin. Register on every context the tools should cover:
124+
125+
```php
126+
add_action( 'wp_head', 'my_register_tools' );
127+
add_action( 'admin_head', 'my_register_tools' );
128+
```
129+
130+
Documents WordPress renders no head for — `admin-ajax.php`, REST routes, static files, a PDF — carry no tools, and the previous page's tools are withdrawn rather than left behind to fail when called.
131+
132+
A browser tab shows one site at a time, so the tools always belong to the active site. Switching sites restarts the proxy against the new one, and tool names need no per-site qualifier.
133+
134+
`document.modelContext` is provided by Playground's mu-plugin on `wp_head` and `admin_head`, which covers the front end and wp-admin.
135+
136+
Chrome 150 deprecated `navigator.modelContext` in favour of `document.modelContext` but still serves it, so the mu-plugin does the same: inside the WordPress document the deprecated global returns the very same registry and warns once on first access. A plugin that has not migrated keeps working here exactly as it does in Chrome. Write new code against `document.modelContext` — the alias goes when Chrome removes it.
137+
138+
**The login screen is deliberately not covered.** `wp-login.php` fires neither hook, so that document has no registry and proxies no tools; a site's tools come back when the user leaves it. `login_head` still fires there, so a plugin hooking it must feature-detect rather than assume the registry exists — worth doing anywhere, since most browsers do not implement WebMCP:
139+
140+
```php
141+
add_action( 'login_head', function () {
142+
?>
143+
<script>
144+
if ( document.modelContext ) {
145+
document.modelContext.registerTool( { /* … */ } );
146+
}
147+
</script>
148+
<?php
149+
} );
150+
```
151+
152+
### Reading the tools
153+
154+
Playground's own tools and the site's tools both live on `document.modelContext`:
155+
156+
```js
157+
document.modelContext.tools.map((tool) => tool.name);
158+
159+
await document.modelContext.tools.find((tool) => tool.name === 'create_order').execute({ sku: 'X' });
160+
```
161+
162+
WebMCP is a draft, so this needs a browser that implements it: Chrome behind `chrome://flags/#enable-webmcp-testing`, or an extension that provides `document.modelContext` itself. Playground registers into whichever it finds and registers nothing when there is none — the site's tools still cross the frame boundary either way, so an embedder can reach them through `PlaygroundClient.onWebMCPToolsChanged()` and `callWebMCPTool()` regardless of browser support.
163+
164+
If a tool is missing, switch the devtools console to the `wp` frame and run `document.modelContext.tools.map( t => t.name )`. An empty list there means the plugin never registered; a list there but not on the page means the announcement did not cross the frame boundary.
165+
89166
## Security
90167

91168
The MCP bridge runs locally and is only accessible from your machine — connections are origin-restricted and require a token generated at server startup, preventing other websites from hijacking it.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
export { startMcpBridge } from './bridge-client';
22
export type { PlaygroundBridgeConfig, McpBridgeHandle } from './bridge-client';
33
export { registerWebMCPTools } from './webmcp';
4+
export { startWebMCPSiteToolProxy } from './webmcp-site-tools';
5+
export type { WebMCPSiteToolProxy } from './webmcp-site-tools';
Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,149 @@
1+
/**
2+
* Re-advertises the WebMCP tools registered inside the WordPress document as
3+
* tools of the Playground page itself.
4+
*
5+
* A plugin running in Playground can register tools with
6+
* `document.modelContext`, but that registry lives in a nested,
7+
* service-worker-served iframe an agent never sees. This proxy mirrors those
8+
* tools onto the top-level `document.modelContext` and forwards each call back
9+
* into the WordPress document:
10+
*
11+
* agent → document.modelContext (this page)
12+
* → PlaygroundClient.callWebMCPTool() (Comlink)
13+
* → remote frame (postMessage)
14+
* → WordPress document (the plugin's execute())
15+
*/
16+
17+
import type {
18+
PlaygroundClient,
19+
WebMCPToolDescriptor,
20+
} from '@wp-playground/remote';
21+
import { logger } from '@php-wasm/logger';
22+
import {
23+
toolDefinitions,
24+
getSiteToolDefinitions,
25+
stringifyError,
26+
} from './tools/tool-definitions';
27+
import type { ModelContextTool } from './webmcp';
28+
import { getModelContext } from './webmcp';
29+
30+
export interface WebMCPSiteToolProxy {
31+
stop(): void;
32+
}
33+
34+
/**
35+
* Starts mirroring `client`'s in-site WebMCP tools onto this page.
36+
*
37+
* Every announcement carries the full tool list, so the previous registration
38+
* is aborted and the new list registered in its place. Returns a handle that
39+
* unregisters everything it registered.
40+
*/
41+
export function startWebMCPSiteToolProxy(
42+
client: PlaygroundClient
43+
): WebMCPSiteToolProxy {
44+
const maybeModelContext = getModelContext();
45+
if (!maybeModelContext) {
46+
// Nothing can be advertised, so there is nothing to observe for.
47+
return { stop: () => {} };
48+
}
49+
// `register` is hoisted, so it does not inherit the narrowing above.
50+
const modelContext = maybeModelContext;
51+
52+
const reserved = reservedToolNames();
53+
let registration: AbortController | null = null;
54+
let stopped = false;
55+
let registeredDescriptors = '';
56+
57+
async function register(tools: WebMCPToolDescriptor[]) {
58+
if (stopped) {
59+
return;
60+
}
61+
// Registration is asynchronous, so a burst of announcements could
62+
// otherwise re-register an unchanged list several times over.
63+
const descriptors = JSON.stringify(tools);
64+
if (descriptors === registeredDescriptors) {
65+
return;
66+
}
67+
registeredDescriptors = descriptors;
68+
69+
registration?.abort();
70+
registration = new AbortController();
71+
const { signal } = registration;
72+
73+
for (const tool of tools) {
74+
if (reserved.has(tool.name)) {
75+
logger.warn(
76+
`Skipping the WebMCP tool "${tool.name}" registered by ` +
77+
`the site: Playground already provides a tool under ` +
78+
`that name.`
79+
);
80+
continue;
81+
}
82+
try {
83+
await modelContext.registerTool(proxyTool(client, tool), {
84+
signal,
85+
});
86+
} catch (error) {
87+
logger.warn(
88+
`Failed to register the site's WebMCP tool ` +
89+
`"${tool.name}": ${stringifyError(error)}`
90+
);
91+
}
92+
if (signal.aborted) {
93+
return;
94+
}
95+
}
96+
}
97+
98+
client
99+
.onWebMCPToolsChanged((tools: WebMCPToolDescriptor[]) => {
100+
void register(tools);
101+
})
102+
.catch((error) => {
103+
logger.warn("Failed to observe the site's WebMCP tools:", error);
104+
});
105+
106+
return {
107+
stop() {
108+
stopped = true;
109+
registration?.abort();
110+
registration = null;
111+
registeredDescriptors = '';
112+
},
113+
};
114+
}
115+
116+
/**
117+
* Names Playground registers itself. A site tool may not take one of them
118+
* over, or an agent asking to execute PHP would reach the site's plugin
119+
* instead.
120+
*/
121+
function reservedToolNames(): Set<string> {
122+
return new Set([
123+
...Object.keys(toolDefinitions),
124+
...Object.keys(getSiteToolDefinitions()),
125+
]);
126+
}
127+
128+
function proxyTool(
129+
client: PlaygroundClient,
130+
tool: WebMCPToolDescriptor
131+
): ModelContextTool {
132+
return {
133+
name: tool.name,
134+
description: tool.description,
135+
inputSchema: tool.inputSchema,
136+
annotations: tool.annotations,
137+
execute: async (input) => {
138+
try {
139+
return await client.callWebMCPTool(tool.name, input ?? {});
140+
} catch (error) {
141+
return {
142+
error:
143+
`Error running the WordPress site's "${tool.name}" ` +
144+
`tool: ${stringifyError(error)}`,
145+
};
146+
}
147+
},
148+
};
149+
}

‎packages/playground/mcp/src/webmcp.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const siteToolDefinitions = getSiteToolDefinitions();
2121

2222
// -- WebMCP type declarations --
2323

24-
interface ModelContextTool {
24+
export interface ModelContextTool {
2525
name: string;
2626
description: string;
2727
inputSchema?: Record<string, unknown>;
@@ -146,7 +146,7 @@ export async function registerWebMCPTools(
146146
}
147147
}
148148

149-
function getModelContext(): ModelContext | undefined {
149+
export function getModelContext(): ModelContext | undefined {
150150
if (typeof document === 'undefined') {
151151
return undefined;
152152
}

0 commit comments

Comments
 (0)