Skip to content

Commit 40515af

Browse files
committed
Start MCP server bridge only if a mcp query argument was provided
1 parent d77d72e commit 40515af

7 files changed

Lines changed: 80 additions & 51 deletions

File tree

‎package-lock.json‎

Lines changed: 10 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎packages/docs/site/docs/developers/06-apis/query-api/01-index.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,8 @@ You can go ahead and try it out. The Playground will automatically install the t
4444
| `gutenberg-branch` | | Installs a specific branch from https://github.com/WordPress/gutenberg. Accepts the branch name. For example, `gutenberg-branch=trunk`. |
4545
| `page-title` | | Customizes the browser tab title. Useful for identifying different Playground instances when working with multiple tabs. The parameter is preserved when navigating between sites. |
4646
| `can-save` | | Default functionality allows for saving Playgrounds to the user's computer or browser. If you would like to turn off the ability for users to save their Playground, add the `?can-save=no` parameter, and options to save will be removed from the UI. |
47+
| `mcp` | | Starts the MCP (Model Context Protocol) server bridge, allowing external MCP clients to connect to and control the Playground instance. Does not accept any values. If `mcp` is added as a URL parameter, the bridge will be started. |
48+
| `mcp-port` | `7999` | Sets the WebSocket port used by the MCP bridge to communicate with the MCP server. Must be used together with `mcp`. For example, `mcp&mcp-port=8080`. |
4749

4850
For example, the following code embeds a Playground with a preinstalled Gutenberg plugin and opens the post editor:
4951

‎packages/playground/mcp/e2e/mcp-tools.spec.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ const test = base.extend<McpTestFixtures, McpWorkerFixtures>({
5656
async ({ browser, mcpClient }, use) => {
5757
const page = await browser.newPage();
5858
await page.goto(
59-
`http://127.0.0.1:5400/website-server/?mcpPort=${MCP_WS_PORT}`
59+
`http://127.0.0.1:5400/website-server/?mcp&mcp-port=${MCP_WS_PORT}`
6060
);
6161

6262
// Wait for WordPress to load inside the nested iframes
@@ -162,7 +162,7 @@ test.afterEach(async ({ mcpClient, playgroundPage, browser }) => {
162162

163163
if (needsReset) {
164164
await playgroundPage.goto(
165-
`http://127.0.0.1:5400/website-server/?mcpPort=${MCP_WS_PORT}`
165+
`http://127.0.0.1:5400/website-server/?mcp&mcp-port=${MCP_WS_PORT}`
166166
);
167167
await waitForActiveSite(mcpClient, 60_000, { probe: false });
168168
}
@@ -207,7 +207,7 @@ test('playground_open_site activates an inactive site in a new tab', async ({
207207
// new temporary site (active) while loading the saved site
208208
// from OPFS (inactive).
209209
await playgroundPage.goto(
210-
`http://127.0.0.1:5400/website-server/?mcpPort=${MCP_WS_PORT}`
210+
`http://127.0.0.1:5400/website-server/?mcp&mcp-port=${MCP_WS_PORT}`
211211
);
212212
await expect(
213213
playgroundPage

‎packages/playground/mcp/src/bridge-client.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,10 @@ export interface McpBridgeHandle {
2828
const DEFAULT_MCP_WS_PORT = 7999;
2929
const RECONNECT_INTERVAL_MS = 5000;
3030

31-
export function startMcpBridge(config: PlaygroundConfig): McpBridgeHandle {
31+
export function startMcpBridge(
32+
config: PlaygroundConfig,
33+
port?: number
34+
): McpBridgeHandle {
3235
const tabId = crypto.randomUUID();
3336
let ws: WebSocket | null = null;
3437
let previousSitesSerialized = '';
@@ -46,11 +49,10 @@ export function startMcpBridge(config: PlaygroundConfig): McpBridgeHandle {
4649
}
4750

4851
function connect() {
49-
const params = new URLSearchParams(window.location.search);
50-
const port = params.get('mcpPort') ?? String(DEFAULT_MCP_WS_PORT);
52+
const wsPort = port ?? DEFAULT_MCP_WS_PORT;
5153

5254
try {
53-
ws = new WebSocket(`ws://127.0.0.1:${port}`);
55+
ws = new WebSocket(`ws://127.0.0.1:${wsPort}`);
5456
} catch {
5557
scheduleReconnect();
5658
return;

‎packages/playground/mcp/src/mcp-server.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,9 @@ export function createServer(): McpServer {
1313
'Use this server when you need a live WordPress environment without any local setup. ' +
1414
"WordPress Playground runs entirely in the user's browser tab via WebAssembly — no PHP, MySQL, " +
1515
'or server required. You are automatically authenticated as an admin user.\n\n' +
16+
'PREREQUISITE: The user must have WordPress Playground open in their browser with the ?mcp ' +
17+
'query parameter (e.g. https://playground.wordpress.net/?mcp). Without it, the MCP bridge ' +
18+
'is not active and no tools will work.\n\n' +
1619
'Typical workflow: playground_list_sites → playground_save_site → filesystem/PHP operations ' +
1720
'→ playground_navigate to verify results.\n\n' +
1821
'Capabilities: execute arbitrary PHP with full WordPress access, read/write files in the virtual filesystem ' +

‎packages/playground/mcp/src/tools/tool-definitions.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,6 +389,10 @@ export const siteToolDefinitions: Record<string, ToolDefinition> = {
389389
tool — it returns the siteId required by every
390390
other operation.
391391
392+
If this returns no sites, the user may need to
393+
open Playground with the ?mcp query parameter
394+
(e.g. https://playground.wordpress.net/?mcp).
395+
392396
Returns site names and storage type. "temporary"
393397
sites are lost on page reload, "opfs" sites persist
394398
across reloads. Call playground_save_site to persist

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

Lines changed: 52 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -25,53 +25,63 @@ startListening({
2525
// Only start the bridge once.
2626
listenerApi.unsubscribe();
2727

28+
// Only start the MCP bridge when explicitly requested via ?mcp query parameter.
29+
const query = new URLSearchParams(window.location.search);
30+
if (!query.has('mcp')) {
31+
return;
32+
}
33+
34+
const mcpPort = query.get('mcp-port');
2835
const { getState, dispatch } = listenerApi;
29-
const handle: McpBridgeHandle = startMcpBridge({
30-
getSites: () => {
31-
const state = getState();
32-
const allSites = selectAllSites(state);
33-
const active = selectActiveSite(state);
34-
return allSites.map((s) => ({
35-
slug: s.slug,
36-
name: s.metadata.name,
37-
storage: s.metadata.storage,
38-
isActive: s.slug === active?.slug,
39-
}));
40-
},
41-
getPlaygroundClient: (siteSlug: string) =>
42-
selectClientBySiteSlug(getState(), siteSlug),
43-
renameSite: async (siteSlug: string, newName: string) => {
44-
await dispatch(
45-
updateSiteMetadata({
46-
slug: siteSlug,
47-
changes: { name: newName },
48-
})
49-
);
50-
},
51-
saveSite: async (siteSlug: string) => {
52-
const state = getState();
53-
const site = selectSiteBySlug(state, siteSlug);
54-
if (!site) {
55-
throw new Error(`Site not found: ${siteSlug}`);
56-
}
57-
if (site.metadata.storage !== 'none') {
36+
const handle: McpBridgeHandle = startMcpBridge(
37+
{
38+
getSites: () => {
39+
const state = getState();
40+
const allSites = selectAllSites(state);
41+
const active = selectActiveSite(state);
42+
return allSites.map((s) => ({
43+
slug: s.slug,
44+
name: s.metadata.name,
45+
storage: s.metadata.storage,
46+
isActive: s.slug === active?.slug,
47+
}));
48+
},
49+
getPlaygroundClient: (siteSlug: string) =>
50+
selectClientBySiteSlug(getState(), siteSlug),
51+
renameSite: async (siteSlug: string, newName: string) => {
52+
await dispatch(
53+
updateSiteMetadata({
54+
slug: siteSlug,
55+
changes: { name: newName },
56+
})
57+
);
58+
},
59+
saveSite: async (siteSlug: string) => {
60+
const state = getState();
61+
const site = selectSiteBySlug(state, siteSlug);
62+
if (!site) {
63+
throw new Error(`Site not found: ${siteSlug}`);
64+
}
65+
if (site.metadata.storage !== 'none') {
66+
return {
67+
slug: siteSlug,
68+
storage: site.metadata.storage,
69+
};
70+
}
71+
await dispatch(
72+
persistTemporarySite(siteSlug, 'opfs', {
73+
skipRenameModal: true,
74+
})
75+
);
76+
const updatedSite = selectSiteBySlug(getState(), siteSlug);
5877
return {
5978
slug: siteSlug,
60-
storage: site.metadata.storage,
79+
storage: updatedSite?.metadata.storage ?? 'none',
6180
};
62-
}
63-
await dispatch(
64-
persistTemporarySite(siteSlug, 'opfs', {
65-
skipRenameModal: true,
66-
})
67-
);
68-
const updatedSite = selectSiteBySlug(getState(), siteSlug);
69-
return {
70-
slug: siteSlug,
71-
storage: updatedSite?.metadata.storage ?? 'none',
72-
};
81+
},
7382
},
74-
});
83+
mcpPort ? Number(mcpPort) : undefined
84+
);
7585

7686
// Notify the bridge when site-related state changes so it
7787
// can diff the site list and re-register when needed.

0 commit comments

Comments
 (0)