Repository navigation
[Playground Website] Playground MCP server - #3290
Conversation
8824a97 to
901797b
Compare
There was a problem hiding this comment.
Pull request overview
Adds a Model Context Protocol (MCP) server + browser WebSocket bridge so AI clients can control and interact with a WordPress Playground instance running in the Playground website.
Changes:
- Introduces a new
@wp-playground/mcppackage implementing an MCP stdio server, WebSocket bridge server, and tool set (site management, PHP execution, filesystem). - Integrates a browser-side bridge client into the Playground website via Redux listener middleware.
- Adds Playwright E2E coverage and a dedicated CI job to run MCP tests on Node 22.
Reviewed changes
Copilot reviewed 24 out of 25 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| tsconfig.base.json | Adds TS path aliases for the new MCP package and bridge client entrypoint. |
| packages/vite-extensions/vite-external-modules.ts | Marks process as external for bundling the MCP package. |
| packages/playground/website/src/lib/state/redux/store.ts | Hooks MCP Redux listener middleware into the website store. |
| packages/playground/website/src/lib/state/redux/slice-sites.ts | Makes updateSiteMetadata fail fast when the target site is missing. |
| packages/playground/website/src/lib/state/redux/init-mcp-bridge.ts | New Redux listener that starts the browser bridge and keeps site registrations in sync. |
| packages/playground/mcp/vite.config.ts | New Vite library build config for MCP server + bridge-client dual entrypoints. |
| packages/playground/mcp/tsconfig.lib.json | New TS config for declarations/build output. |
| packages/playground/mcp/tsconfig.json | New TS project reference config for the MCP package. |
| packages/playground/mcp/src/tools/utils.ts | Shared tool utilities (siteId schema, error formatting, byte decoding). |
| packages/playground/mcp/src/tools/site-management.ts | Implements MCP site management tools. |
| packages/playground/mcp/src/tools/filesystem.ts | Implements MCP filesystem tools. |
| packages/playground/mcp/src/tools/code-execution.ts | Implements MCP PHP execution + HTTP request tools. |
| packages/playground/mcp/src/mcp-server.ts | Creates MCP server instance and registers tools. |
| packages/playground/mcp/src/index.ts | CLI entrypoint: starts WS server + attaches stdio MCP transport. |
| packages/playground/mcp/src/bridge-server.ts | WebSocket bridge server coordinating tabs/sites and routing commands. |
| packages/playground/mcp/src/bridge-client.ts | Browser bridge client that registers sites and proxies commands to PlaygroundClient. |
| packages/playground/mcp/project.json | Nx project config (build, lint, and E2E test target). |
| packages/playground/mcp/playwright.config.ts | Playwright config for MCP E2E runs. |
| packages/playground/mcp/package.json | New publishable package metadata, exports, bin, and deps. |
| packages/playground/mcp/e2e/mcp-tools.spec.ts | E2E suite exercising all MCP tools via real browser + MCP client. |
| packages/playground/mcp/README.md | Usage and development docs for the MCP package. |
| packages/playground/mcp/.eslintrc.json | ESLint config for the new package. |
| package.json | Adds repo-level dependencies used by MCP (@modelcontextprotocol/sdk, zod). |
| .github/workflows/ci.yml | Adds a dedicated Node 22 CI job to run MCP E2E tests. |
Comments suppressed due to low confidence (8)
packages/playground/mcp/src/index.ts:14
Number(...)can returnNaNfor invalid values (e.g.--port=abcorMCP_WS_PORT=abc). That will be passed tonew WebSocketServer({ port })and fail in a less actionable way. Validate that the parsed value is a finite integer within the valid port range (1–65535) and fall back toDEFAULT_WS_PORT(or throw a clear error).
function getPort(): number {
const portArg = process.argv.find((a) => a.startsWith('--port='));
if (portArg) {
return Number(portArg.split('=')[1]);
}
if (process.env['MCP_WS_PORT']) {
return Number(process.env['MCP_WS_PORT']);
}
return DEFAULT_WS_PORT;
}
packages/playground/mcp/src/bridge-server.ts:70
- The error message instructs users to change
WS_PORT, but the implementation actually reads--port=...andMCP_WS_PORT. Update the message to reference the correct flag/env var (e.g.--port/MCP_WS_PORT) so users can resolve the conflict quickly.
console.error(
`[MCP] Port ${port} is already in use. ` +
`Kill the other process (lsof -i :${port}) or ` +
`change WS_PORT.`
);
packages/playground/mcp/src/bridge-server.ts:127
JSON.stringify(message.error)can throw (e.g. circular structures). If it throws, it will be caught by the outertry, but the pending request will remain unresolved (leaking until timeout). Use a safe stringifier (try/catch) and always resolve/reject + delete the pending request in a way that cannot throw.
if (message.error) {
const errorMsg =
typeof message.error === 'string'
? message.error
: JSON.stringify(message.error);
pending.reject(new Error(errorMsg));
} else {
pending.resolve(message.value);
}
packages/playground/mcp/src/bridge-server.ts:308
- If
ws.send(...)throws (e.g. socket closing/not open), the Promise will reject but the entry inpendingRequestsand the timeout will remain, causing a memory leak and a later timeout handler to fire. Wrapws.sendin a try/catch and, on failure, clear the timeout and deletependingRequests[id]before rejecting.
this.pendingRequests.set(id, {
resolve: (value: unknown) => {
clearTimeout(timeout);
resolve(value);
},
reject: (error: Error) => {
clearTimeout(timeout);
reject(error);
},
tabId: targetTabId,
});
ws.send(
JSON.stringify({
id,
type: 'command',
method,
args,
siteSlug: site.siteSlug,
})
);
packages/playground/mcp/src/bridge-client.ts:60
JSON.parseis not guarded here. A single malformed/partial frame (or unexpected payload) would throw and break the message handler, potentially leaving the bridge in a stuck state. Wrap parsing in try/catch and ignore (or log) invalid messages.
ws.addEventListener('message', async (event) => {
const message = JSON.parse(event.data as string);
if (message.type !== 'command') {
return;
}
packages/playground/mcp/src/bridge-client.ts:97
scheduleReconnect()can queue multiple concurrent reconnect timers (e.g. repeatedcloseevents, or connect failures in quick succession), causing overlapping connection attempts. Track the timeout id and avoid scheduling a new reconnect if one is already pending; also clear it on successfulopen.
function scheduleReconnect() {
setTimeout(connect, RECONNECT_INTERVAL_MS);
}
packages/playground/mcp/src/mcp-server.ts:11
- The MCP server version is hard-coded to
0.1.0while the package version is3.1.1. This discrepancy can confuse MCP clients and diagnostics. Consider sourcing the version from the package version at build time (or keeping the server version aligned withpackage.json).
return new McpServer({
name: 'wordpress-playground',
version: '0.1.0',
});
packages/playground/mcp/playwright.config.ts:9
- This comment conflicts with the PR description and implementation, which support multiple tabs. If the limitation is specific to the test setup (e.g. to avoid cross-test interference), reword the comment to reflect that, rather than stating the server can’t handle multiple connections.
// Must be 1: the MCP server supports only one browser connection at a time.
workers: 1,
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| listenerApi.unsubscribe(); | ||
|
|
||
| const { getState, dispatch } = listenerApi; | ||
| const handle: McpBridgeHandle = startMcpBridge({ |
There was a problem hiding this comment.
Currently the Websocket client starts automatically. This is good for MCP users, but for everyone else it's just taking up resources.
We could introduce a mcp query argument to start the MCP server, or a checkbox in the UI.
There was a problem hiding this comment.
Agreed. It doesn't seem good to enable it by default.
It feels like we should have some toggle in the UI or find another way to avoid enabling it by default. Adding a query arg sounded good to me at first, but unlike other query args, it affects the entire Playground web app rather than a single site loaded within the web app.
|
I've pushed a couple of changes that will enable us to easily add WebMCP support the MCP package. |
|
Thank you for working on this! My intuition feels troubled by the need to:
It is probably fine, but I can't totally explain the feeling I have and want to sleep on it. Here are a couple thoughts, but they don't full explain the feeling:
@bgrgicak, how are you thinking about MCP versus WebMCP? I think MCP is better for now, but something like WebMCP is probably the future. We need to serve the now, but maybe we can do so in a way that does not leave a lasting mark on the Playground website. Maybe having an |
|
One other question: |
Yes, WebMCP is exactly what we need, but it's still a draft, not a standard. Also, it's currently unsupported. The only way to test it is with a Chrome extension in Canary. If WebMCP were ready to be used today, I wouldn't bother with the MCP server, but there's currently no production-ready alternative to MCP servers.
I will test this today and share my findings.
I agree, there needs to be a way to start the server. I can add that to the PR. |
DevTools MCP can access the Playground Client global and control the browser, so it's able to do everything the current MCP implementation can do. Site management features are fragile as it needs to open menus and click buttons, but it wouldn't be hard to add a separate global like DevTools MCP doesn't provide any instructions to AI about Playground or how to use the DevTools MCP tools in the Playground context, but this could be explained with a skill. The only real advantage of the Playground MCP is that it's a purpose-built tool and doesn't require combining skills and MCPs because all descriptions and tool usage instructions are built into the MCP server. |
|
I'm not sure what the best solution would be here. Only Chrome comes with a built-in DevTools MCP server. Safari and Firefox require separate MPC servers that work in a similar way to the Playground MCP. Maintaining a skills file compared to an MCP server is easier, but the MCP server will be more robust than relying on agents to correctly interpret skills. Having the ability for DevMCP to do the same as Playground MCP sounds logical and will long-term make it easier to support new integrations. |
|
Thanks for all this discussion, @bgrgicak!
It sounds like we need to provide an MCP for now. Would there be any value in making this an experimental feature with a query arg like
Should we disable the use of arbitrary blueprints by default when the MCP is enabled?
This seems like a great thing to add to the global API, whether added here or in another PR.
This is a great point. Providing a direct vocabulary for working with Playgrounds is more efficient for people and models than assembling a multi-step path to the same goal. No step integration is required.
Maybe we can just support both and try to measure which ones folks are using. Does this sound reasonable? |
5c3bd15 to
40515af
Compare
I added a
I'm not sure, it reduces the attack surface a bit, but you can still install plugins, and open existing saved sites. This is a valid point, and it might be worth disabling it for now and consider enabling in the future only if users ask for it.
I'm considering adding it in a separate PR together with the DevTools MCP skill, but I will protype it first to see how large the change is.
We can add Google Analytics events to detect MCP tool calls, but can't think of a way to add analytics events for DevTools MCP. |
|
@brandonpayton this is now ready for review.
I'm working on this in a separate PR, it won't be a big change, but I want us to review and discuss it separately from the MCP server as it could be used in multiple ways. |
brandonpayton
left a comment
There was a problem hiding this comment.
Hi! I left some comments and questions.
After looking at the feedback, please feel free to merge this whenever you think it is ready.
Thanks for this, @bgrgicak!
| | `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. | | ||
| | `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. | | ||
| | `mcp` | `no` | Starts the MCP (Model Context Protocol) server bridge, allowing external MCP clients to connect to and control the Playground instance. Accepts `yes` or `no`. | | ||
| | `mcp-port` | `7999` | Sets the WebSocket port used by the MCP bridge to communicate with the MCP server. Must be used together with `mcp=yes`. For example, `mcp=yes&mcp-port=8080`. | |
There was a problem hiding this comment.
I was coming to request the ability to set the port and found you'd already added it. 🙌 🙇
| throw new Error( | ||
| `Starting a new Playground from a Blueprint is disabled when the MCP server | ||
| is active to prevent potential prompt injection vulnerabilities. | ||
| Please remove the Blueprint hash from your URL or | ||
| disable the MCP server by removing the "mcp=yes" query parameter.` | ||
| ); |
There was a problem hiding this comment.
I like starting here because we can easily remove this restriction if it makes sense. Thank you!
| get: | ||
| (_target, method: string) => | ||
| (...args: unknown[]) => | ||
| sendCommand(siteId, method, args), |
There was a problem hiding this comment.
Do we have a command allow-list anywhere? If not, is there any reason we shouldn't enforce an allow-list? Allowing arbitrary commands doesn't seem great, even if it may normally fail.
There was a problem hiding this comment.
We have a check at the start of the loop that ensures we only register defined tools, but I didn't like the Proxy approach and ended up just defining all tools 38de5ea
| }); | ||
| } | ||
|
|
||
| export const registerMcpServerTools: ToolRegistrar = (server, bridge, port) => { |
There was a problem hiding this comment.
nit: Why not export a named function instead of an arrow function? It's possible dev tools do better nowadays, but by default, named functions have more useful labels when examining the call stack.
There was a problem hiding this comment.
Done (524b98d), all other functions are named functions so it makes sense for it to be named.
|
|
||
| const require = createRequire(import.meta.url); | ||
| const { version: packageVersion } = | ||
| require('../package.json') ?? require('./package.json'); |
There was a problem hiding this comment.
AFAIK, require() throws if it fails, so null coalescing will not work here.
There was a problem hiding this comment.
Thanks! Not sure how I missed that, fixed in 7954976
|
|
||
| // Use a random port so tests are isolated from real browser tabs | ||
| // that might also connect to the default MCP WebSocket port. | ||
| const MCP_WS_PORT = 17999 + Math.floor(Math.random() * 1000); |
There was a problem hiding this comment.
Could we just use 0 here so the system picks a free port?
There was a problem hiding this comment.
We use 0 as the default port now in the MCP server. Only tests override the port, so they know what Playground URL to open.
| await mcpClient.callTool({ | ||
| name: 'playground_delete_directory', | ||
| arguments: { siteId, path: testDir }, | ||
| }); |
There was a problem hiding this comment.
Do we need a test for the recursive version of this command?
| activeInTabs: string[]; | ||
| } | ||
|
|
||
| export const DEFAULT_WS_PORT = 7999; |
There was a problem hiding this comment.
Would there be any value in just making this zero so it always picks an available port? Do we need a default port number when agents can just specify the port of the running MCP server when using Playground?
| ws.addEventListener('open', () => { | ||
| previousSitesSerialized = ''; | ||
| sendSitesRegistration(ws!); | ||
| config.onConnect?.(); | ||
| }); |
There was a problem hiding this comment.
Would there be any value in requiring a secret (like a random UUID) to be shared between client and server to successfully connect to the bridge server?
Is there any kind of attack that would discourage? It's late here, and I am tired. But it feels like maybe it is a good idea to explicitly confirm the server is only used by a client approved by the entity who owns them both.
…y non-empty directory removal
|
Regarding ports and requiring a secret, I have a draft implementation that does a few things to make the MCP server more secure:
The dynamic port will ensure users need to open the correct Playground URL for the MCP server to connect to Playground. MCP clients won't be able to just start connecting to the Playground website if it's open in the browser. The origin check and token will prevent sites from connecting to the MCP server. I pushed the changes in b0778c8, but I need to spend more time testing it. |
Awesome! |
|
This is ready to be merged. I tested it locally using Once CI tests pass, I will merge it and publish to NPM. |
|
This is great, @bgrgicak! Requiring a secret token and using random ports really tightened this up. Thank you! |
## Motivation for the change, related issues Adds WebMCP support to the Playground website, so browser-native AI agents (via Chrome's `navigator.modelContext` API) can interact with Playground directly — no WebSocket bridge or Node.js server needed. The MCP server in #3290 requires a Node.js process as an intermediary between AI clients and the browser. Chrome's experimental WebMCP API (`navigator.modelContext`) lets the browser expose tools directly to built-in AI features, removing that hop entirely. ## Implementation details ### WebMCP tool registration - **`webmcp.ts`** — New module that registers all 16 playground tools with `navigator.modelContext.registerTool()`. Calls `PlaygroundClient` directly instead of going through the WebSocket bridge. Silently no-ops when `navigator.modelContext` is unavailable. - **`client.ts`** — Exports `registerWebMCPTools` alongside `startMcpBridge` from the `@wp-playground/mcp/client` entry point. - **`README.md`** — Adds a WebMCP section documenting the feature and its draft status. ### Shared `mcpConfig` refactor - **`init-mcp-bridge.ts`** — Extracts the `mcpConfig` object (site listing, client lookup, save, rename) so it's created once and shared between `registerWebMCPTools()` and `startMcpBridge()`. WebMCP tools are registered unconditionally on page load; the WebSocket bridge still requires `?mcp=yes`. ### Test reorganization and infrastructure - Moves e2e tests from `e2e/` to `tests/e2e/` and unit tests from `src/` to `tests/unit/` for consistent structure. - Splits the `test:mcp` target in `project.json` into `test:unit` (Vitest) and `test:e2e` (Playwright), with `test:mcp` as a noop that depends on both. - Adds `tsconfig.spec.json` and Vitest config in `vite.config.ts` to support unit tests in the new `tests/` directory. - **`webmcp.spec.ts`** — Playwright e2e tests that mock `navigator.modelContext` via `addInitScript`, then exercise tool registration and execution (PHP, filesystem, site management). ## Testing Instructions - Go to chrome://flags and enable the "WebMCP for testing" flag in Chrome 148.0.7757.0 or higher - Install the [WebMCP - Model Context Tool Inspector Chrome extension](https://chromewebstore.google.com/detail/webmcp-model-context-tool/gbpdfapgefenggkahomfgkhfehlcenpd) - Open the local version of the Playground website and start the Chrome extension - Run some test prompts like "Install plugin A", "Navigate to WP Admin" --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Copilot <198982749+Copilot@users.noreply.github.com> Co-authored-by: bgrgicak <1199991+bgrgicak@users.noreply.github.com> Co-authored-by: Francesco Bigiarini <francesco.bigiarini@gmail.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Motivation for the change, related issues
This PR adds an MCP (Model Context Protocol) server that connects AI assistants like Claude and Gemini CLI to a WordPress Playground running in the browser.
AI assistants can read and write local files, but they can't interact with a running WordPress instance in the Playground Website. This MCP server bridges that gap — it lets AI tools execute PHP, manage files on the WordPress virtual filesystem, make HTTP requests, and control site state, all through the standard MCP protocol.
This turns Playground into a live sandbox where an AI can build, test, and iterate on WordPress plugins and themes.
While ChatGPT supports MCP, it requires MCP servers to be HTTP servers available on public URLs, so Playground MCP won't work with ChatGPT.
Implementation details
Architecture
The MCP server uses a WebSocket bridge to connect two worlds:
src/index.ts,src/mcp-server.ts,src/tools/): Runs as a stdio-based MCP server using@modelcontextprotocol/sdk. Tool definitions live insrc/tools/tool-definitions.tsas pure data (names, descriptions, params, annotations) so they can be reused by future WebMCP. Registration logic is insrc/tools/register-mcp-server-tools.ts.src/bridge-server.ts): Manages browser tab connections and site registrations. Tracks which sites are active in which tabs, routes commands to the correct tab, and handles tab disconnection gracefully (rejects pending requests, cleans up orphaned sites).src/bridge-client.ts): Runs in the browser inside the Playground website. Connects to the WebSocket server, registers available sites from the Redux store, and proxies MCP commands to thePlaygroundClientAPI. Auto-reconnects every 5 seconds if the connection drops.website/src/lib/state/redux/init-mcp-bridge.ts): A Redux listener middleware that starts the bridge client once sites are loaded, wires upgetSites/getPlaygroundClient/renameSite/saveSitecallbacks from the Redux store, and notifies the bridge on every Redux action so the site list stays in sync.MCP Tools
Four groups of tools, 16 total:
Site management:
playground_list_sites,playground_open_site,playground_rename_site,playground_save_siteCode execution:
playground_execute_php,playground_requestNavigation & info:
playground_navigate,playground_get_current_url,playground_get_site_infoFilesystem:
playground_read_file,playground_write_file,playground_list_files,playground_mkdir,playground_delete_file,playground_delete_directory,playground_file_existsMulti-tab / multi-site support
Multiple browser tabs can connect simultaneously. Each tab registers the sites it knows about (from its Redux store). The bridge deduplicates sites by slug and tracks which tabs have a site active. Commands are routed to the most-recently-active tab for a given site. When a tab disconnects, its pending requests are rejected and its site registrations are cleaned up.
E2E tests
The PR uses Playwright tests (
e2e/mcp-tools.spec.ts) that spin up the MCP server, connect a real browser to the Playground dev server, and exercise every tool through the MCP SDK client. Tests run in CI via a dedicatedtest-e2e-mcpjob.Security
1. Origin validation (blocks Cross-Site WebSocket Hijacking)
The WebSocket API does not enforce Same-Origin Policy — any website the user visits could silently connect to
ws://localhost:7999and issue commands. The bridge validates theOriginheader on every WebSocket handshake and rejects connections from origins not in the Playground allowlist.2. Per-session token (blocks rogue local processes)
A random UUID token is generated at MCP server startup. The bridge exposes it via an origin-validated HTTP endpoint (
GET /bridge-token).The browser fetches it from that endpoint and includes it in the WebSocket URL (
ws://localhost:7999?token=<uuid>). Any connection — browser or local process — that presents a missing or wrong token is rejected.3. Localhost binding (blocks network exposure)
The WebSocket server binds to
127.0.0.1, not0.0.0.0. It is not reachable from the local network.What is not mitigated here: Prompt injection via tool responses (e.g. a compromised WordPress site embedding payloads in PHP output) is a broader MCP ecosystem problem and is out of scope for this PR. A general suggestion is to use the latest large models like Opus instead of small models like Haiku, as they are better at detecting prompt injection.
Testing Instructions
Automated
Start the dev server and run the e2e tests:
Manual
Note: to test it locally you need your default node version to be at least Node 22 or replace the
nodecommand with a path to Node 22+ (e.g./Users/ME/.nvm/versions/node/v22.22.0/bin/node)npm run dev.mcp.json) with:{ "mcpServers": { "wordpress-playground": { "type": "stdio", "command": "node", "args": ["--experimental-strip-types", "--experimental-transform-types", "--import", "ABS_PATH_TO_PLAYGROUND/packages/meta/src/node-es-module-loader/register.mts", "ABS_PATH_TO_PLAYGROUND/packages/playground/mcp/src/index.ts"] } } }Test build
npx nx reset && npm run local-package-repository@wp-playground-mcp-3.1.1.tar.gzURL@wp-playground/mcpwith the URLTODOs
This PR is already getting big, and it offers a working MCP server for Claude and Gemini CLI, so I would like to add additional features in separate PRs.
Here are a few features I had in mind:
requestthat will return markdown content instead of the full HTML or create a separate tool. Reading the full HTML page can quickly fill up the context, and markdown content would enable AI to read pages without polluting the context with HTML.