Skip to content

Commit fd55e7f

Browse files
committed
Proxy the site's WebMCP tools on the Playground website too
The mu-plugin registry and the frame bridge were already shared; only the wiring that re-advertises the tools was specific to Personal Playground. A browser tab shows one site at a time, so the proxy follows the active site's client and restarts when the user switches sites or the site reboots. That is also why tool names need no per-site qualifier. The website resolves the active client by throwing when no site is selected, unlike Personal Playground, so the sync guards for that. The wiring sits before the `?mcp-port` early return: WebMCP is independent of the WebSocket bridge. Claude-Session: https://claude.ai/code/session_01SES9wgwFmrkkhwqfmfMuMU
1 parent f1324f6 commit fd55e7f

3 files changed

Lines changed: 167 additions & 5 deletions

File tree

‎packages/playground/mcp/README.md‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,7 @@ The [Playground Website](https://playground.wordpress.net/) also supports [WebMC
8888
8989
### Tools registered by the WordPress site
9090

91-
A plugin running inside Playground can register its own WebMCP tools, and Personal Playground re-advertises them as tools of the page:
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:
9292

9393
```php
9494
add_action( 'wp_head', function () {
@@ -130,6 +130,8 @@ add_action( 'login_head', 'my_register_tools' );
130130

131131
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.
132132

133+
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.
134+
133135
`navigator.modelContext` (and `document.modelContext`) is provided by Playground's mu-plugin inside every WordPress document, so a plugin can rely on it being there.
134136

135137
### Reading the tools
@@ -142,9 +144,9 @@ document.modelContext.tools.map((tool) => tool.name);
142144
await document.modelContext.tools.find((tool) => tool.name === 'create_order').execute({ sku: 'X' });
143145
```
144146

145-
WebMCP is a draft: Chrome implements it behind `chrome://flags/#enable-webmcp-testing`, and no other browser does. Personal Playground therefore installs a polyfill when the browser has none, so `document.modelContext` is there either way. The polyfill is a registry, not an agent — it holds the tools for whoever asks, and steps aside wherever WebMCP is native.
147+
WebMCP is a draft: Chrome implements it behind `chrome://flags/#enable-webmcp-testing`, and no other browser does. Playground therefore installs a polyfill when the browser has none, so `document.modelContext` is there either way. The polyfill is a registry, not an agent — it holds the tools for whoever asks, and steps aside wherever WebMCP is native.
146148

147-
The dev server also narrows the view to the site's own tools, which is handy while building a plugin:
149+
Personal Playground's dev server also narrows the view to the site's own tools, which is handy while building a plugin:
148150

149151
```js
150152
__PLAYGROUND_WEBMCP__.tools();
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { test, expect } from '@playwright/test';
2+
3+
/**
4+
* A plugin that registers a WebMCP tool inside the WordPress document. The
5+
* tool returns a value only PHP knows, which proves the call really executed
6+
* in the site rather than in the proxy.
7+
*/
8+
const TOOL_PLUGIN = `<?php
9+
add_action('wp_head', function () {
10+
?>
11+
<script>
12+
navigator.modelContext.registerTool({
13+
name: 'site_greeting',
14+
description: 'Greets someone using the WordPress site name.',
15+
inputSchema: {
16+
type: 'object',
17+
properties: { name: { type: 'string' } },
18+
required: ['name'],
19+
},
20+
execute: async (input) => ({
21+
greeting: 'Hello ' + input.name + ' from ' +
22+
<?php echo json_encode(get_bloginfo('name')); ?>,
23+
}),
24+
});
25+
</script>
26+
<?php
27+
}, 20);
28+
`;
29+
30+
/**
31+
* Unlike `webmcp.spec.ts` this installs no `document.modelContext` mock: the
32+
* website's polyfill has to provide it, which is what an ordinary browser
33+
* without WebMCP support relies on.
34+
*/
35+
test('the website proxies tools registered by the site', async ({ page }) => {
36+
await page.goto('/');
37+
await expect(
38+
page
39+
.frameLocator(
40+
'#playground-viewport:visible,.playground-viewport:visible'
41+
)
42+
.frameLocator('#wp')
43+
.locator('body')
44+
).not.toBeEmpty();
45+
46+
const run = (name: string, input: Record<string, unknown> = {}) =>
47+
page.evaluate(
48+
({ name, input }) =>
49+
(document as any).modelContext.tools
50+
.find((tool: { name: string }) => tool.name === name)
51+
.execute(input),
52+
{ name, input }
53+
);
54+
const toolNames = () =>
55+
page.evaluate(() =>
56+
(document as any).modelContext.tools.map(
57+
(tool: { name: string }) => tool.name
58+
)
59+
);
60+
61+
// Playground's tools land on the polyfill before the site has booted, so
62+
// wait until one of them can actually reach the client.
63+
await expect
64+
.poll(
65+
() =>
66+
page.evaluate(async () => {
67+
const tool = (document as any).modelContext?.tools.find(
68+
(candidate: { name: string }) =>
69+
candidate.name === 'playground_get_site_info'
70+
);
71+
if (!tool) {
72+
return false;
73+
}
74+
const info = await tool.execute({});
75+
return typeof info?.wpVersion === 'string';
76+
}),
77+
{ timeout: 60_000, intervals: [1_000] }
78+
)
79+
.toBe(true);
80+
81+
await run('playground_mkdir', { path: '/wordpress/wp-content/mu-plugins' });
82+
await run('playground_write_file', {
83+
path: '/wordpress/wp-content/mu-plugins/webmcp-tool.php',
84+
contents: TOOL_PLUGIN,
85+
});
86+
await run('playground_navigate', { path: '/' });
87+
88+
await expect
89+
.poll(toolNames, { timeout: 60_000, intervals: [1_000] })
90+
.toContain('site_greeting');
91+
92+
const result = await run('site_greeting', { name: 'Playground' });
93+
expect(result.greeting).toContain('Hello Playground from ');
94+
95+
// A document Playground does not inject the registry into announces
96+
// nothing, so the tool must not stay advertised on its predecessor's
97+
// behalf — and must come back on return.
98+
await run('playground_navigate', {
99+
path: '/wp-admin/admin-ajax.php?action=nope',
100+
});
101+
await expect
102+
.poll(toolNames, { timeout: 30_000, intervals: [500] })
103+
.not.toContain('site_greeting');
104+
105+
await run('playground_navigate', { path: '/' });
106+
await expect
107+
.poll(toolNames, { timeout: 30_000, intervals: [500] })
108+
.toContain('site_greeting');
109+
});

‎packages/playground/website/src/lib/state/redux/init-mcp-bridge.ts‎

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,17 @@ import { createListenerMiddleware } from '@reduxjs/toolkit';
22
import type { PlaygroundReduxState, PlaygroundDispatch } from './store';
33
import { setOPFSSitesLoadingState } from './slice-sites';
44
import { createSitesAPI } from './site-management-api-middleware';
5-
import type { McpBridgeHandle } from '@wp-playground/mcp/client';
6-
import { registerWebMCPTools, startMcpBridge } from '@wp-playground/mcp/client';
5+
import type { PlaygroundClient } from '@wp-playground/remote';
6+
import type {
7+
McpBridgeHandle,
8+
WebMCPSiteToolProxy,
9+
} from '@wp-playground/mcp/client';
10+
import {
11+
installWebMCPPolyfill,
12+
registerWebMCPTools,
13+
startMcpBridge,
14+
startWebMCPSiteToolProxy,
15+
} from '@wp-playground/mcp/client';
716
import { isMcpServerEnabled } from '../url/router';
817
import { logTrackingEvent } from '../../tracking';
918
import { logger } from '@php-wasm/logger';
@@ -43,10 +52,52 @@ startListening({
4352
* breaks, we must not let it crash the Playground website — the MCP
4453
* integration is a progressive enhancement, not a critical feature.
4554
*/
55+
// Without WebMCP support there is no `document.modelContext` to
56+
// register into, and neither Playground's tools nor the site's would
57+
// be reachable through the standard API.
58+
installWebMCPPolyfill();
59+
4660
void registerWebMCPTools(mcpConfig).catch((error) => {
4761
logger.warn('WebMCP registration failed:', error);
4862
});
4963

64+
/**
65+
* Mirrors the WebMCP tools a plugin registers inside the WordPress
66+
* document onto this page, so an agent driving Playground can call
67+
* them.
68+
*
69+
* A tab shows one site at a time, so the proxy follows the active
70+
* site's client and restarts when the user switches sites or the site
71+
* reboots. Tool names need no per-site qualifier for the same reason.
72+
*/
73+
let siteToolProxy: WebMCPSiteToolProxy | null = null;
74+
let proxiedClient: PlaygroundClient | undefined;
75+
const syncSiteToolProxy = () => {
76+
let client: PlaygroundClient | undefined;
77+
try {
78+
client = mcpConfig.getClient();
79+
} catch {
80+
// No site is selected yet.
81+
client = undefined;
82+
}
83+
if (client === proxiedClient) {
84+
return;
85+
}
86+
proxiedClient = client;
87+
siteToolProxy?.stop();
88+
siteToolProxy = client ? startWebMCPSiteToolProxy(client) : null;
89+
};
90+
syncSiteToolProxy();
91+
92+
startListening({
93+
predicate: (action) =>
94+
typeof action.type === 'string' &&
95+
(action.type.startsWith('clients/') ||
96+
action.type.startsWith('sites/') ||
97+
action.type === 'ui/setActiveSite'),
98+
effect: syncSiteToolProxy,
99+
});
100+
50101
// Only start the WebSocket bridge when explicitly requested
51102
// via ?mcp-port.
52103
if (!isMcpServerEnabled()) {

0 commit comments

Comments
 (0)