Skip to content

[Playground Website] Playground MCP server - #3290

Merged
bgrgicak merged 37 commits into
trunkfrom
add/playground-website-mcp-server
Mar 10, 2026
Merged

bgrgicak merged 37 commits into
trunkfrom
add/playground-website-mcp-server

Conversation

@bgrgicak

@bgrgicak bgrgicak commented Feb 19, 2026 •

Copy link
Copy Markdown
Collaborator

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:

AI Client (stdio) → MCP Server (Node.js) → WebSocket (port 7999) → Browser (Playground website)
  • MCP Server (src/index.ts, src/mcp-server.ts, src/tools/): Runs as a stdio-based MCP server using @modelcontextprotocol/sdk. Tool definitions live in src/tools/tool-definitions.ts as pure data (names, descriptions, params, annotations) so they can be reused by future WebMCP. Registration logic is in src/tools/register-mcp-server-tools.ts.
  • Bridge Server (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).
  • Bridge Client (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 the PlaygroundClient API. Auto-reconnects every 5 seconds if the connection drops.
  • Website integration (website/src/lib/state/redux/init-mcp-bridge.ts): A Redux listener middleware that starts the bridge client once sites are loaded, wires up getSites/getPlaygroundClient/renameSite/saveSite callbacks 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_site

Code execution: playground_execute_php, playground_request

Navigation & info: playground_navigate, playground_get_current_url, playground_get_site_info

Filesystem: playground_read_file, playground_write_file, playground_list_files, playground_mkdir, playground_delete_file, playground_delete_directory, playground_file_exists

Multi-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 dedicated test-e2e-mcp job.

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:7999 and issue commands. The bridge validates the Origin header 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, not 0.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:

npm run dev  # in one terminal
npx nx e2e playground-mcp  # in another terminal

Manual

Note: to test it locally you need your default node version to be at least Node 22 or replace the node command with a path to Node 22+ (e.g. /Users/ME/.nvm/versions/node/v22.22.0/bin/node)

  1. Start the dev server: npm run dev
  2. Configure your MCP client (e.g. Claude Code .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"]
        }
      }
    }
  3. Open http://127.0.0.1:5400/website-server/
  4. Ask the AI to list sites, execute PHP, read/write files, etc.
test all playground mcp skills
Let's build a site for a cheese grater producer. Make it fun. 
Use the Playground MCP
Install the friends plugin
  1. Confirm tools work and that closing/reopening browser tabs reconnects gracefully

Test build

  • Run npx nx reset && npm run local-package-repository
  • Grab the @wp-playground-mcp-3.1.1.tar.gz URL
  • Configure the MCP server using production instructions and replace @wp-playground/mcp with the URL
  • Start Claude and test the MCP

TODOs

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:

  • Add an extra argument to request that 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.
  • WP-CLI - AI frequently tries to use WP-CLI, but it's not available out of the box.
  • Blueprint tool
  • Import tool
  • WebMCP support
  • Explore support for other AI providers that support MCP.
  • Publish Claude and Gemini extensions.

@bgrgicak bgrgicak self-assigned this Feb 19, 2026
@bgrgicak
bgrgicak force-pushed the add/playground-website-mcp-server branch 2 times, most recently from 8824a97 to 901797b Compare February 19, 2026 13:20
Comment thread .github/workflows/ci.yml
@bgrgicak
bgrgicak marked this pull request as ready for review February 20, 2026 13:51
@bgrgicak
bgrgicak requested a review from a team as a code owner February 20, 2026 13:51
@bgrgicak
bgrgicak requested review from Copilot and zaerl February 20, 2026 13:51

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/mcp package 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 return NaN for invalid values (e.g. --port=abc or MCP_WS_PORT=abc). That will be passed to new 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 to DEFAULT_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=... and MCP_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 outer try, 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 in pendingRequests and the timeout will remain, causing a memory leak and a later timeout handler to fire. Wrap ws.send in a try/catch and, on failure, clear the timeout and delete pendingRequests[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.parse is 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. repeated close events, 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 successful open.
	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.0 while the package version is 3.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 with package.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.

Comment thread packages/playground/mcp/src/tools/site-management.ts Outdated
Comment thread packages/playground/mcp/src/tools/filesystem.ts Outdated
Comment thread packages/playground/mcp/src/tools/code-execution.ts Outdated
Comment thread packages/playground/mcp/src/mcp-server.ts Outdated
listenerApi.unsubscribe();

const { getState, dispatch } = listenerApi;
const handle: McpBridgeHandle = startMcpBridge({

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bgrgicak

Copy link
Copy Markdown
Collaborator Author

I've pushed a couple of changes that will enable us to easily add WebMCP support the MCP package.
The initial PR implementation defined tools and their executors in the MCP server, which prevented us from reusing tools between the MCP server and WebMCP.

@brandonpayton

Copy link
Copy Markdown
Member

Thank you for working on this!

My intuition feels troubled by the need to:

  • run a local server
  • have playground.wordpress.net connect to localhost

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:

  • WebMCP is a simpler, more explicit way of exposing an MCP without requiring a local server
  • How far could we get with the devtools MCP and the exposed Playground API?

@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 mcp-enabling query param would be the way to go, so folks could invoke the MCP without requiring any changes to the web app UI.

@brandonpayton

Copy link
Copy Markdown
Member

One other question:
Could the local server be abused in any way by JS added by a WordPress plugin or blueprint?

@bgrgicak

bgrgicak commented Mar 3, 2026

Copy link
Copy Markdown
Collaborator Author

WebMCP is a simpler, more explicit way of exposing an MCP without requiring a local server

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.

How far could we get with the devtools MCP and the exposed Playground API?

I will test this today and share my findings.

Maybe having an mcp-enabling query param would be the way to go, so folks could invoke the MCP without requiring any changes to the web app UI.

I agree, there needs to be a way to start the server. I can add that to the PR.

@bgrgicak

bgrgicak commented Mar 3, 2026

Copy link
Copy Markdown
Collaborator Author

Could the local server be abused in any way by JS added by a WordPress plugin or blueprint?

Yes, any context can be the source of prompt injection. It's not specific to MCP servers or Playground MCP.
Here's an example where a plugin does prompt injection by printing content to the page.

Screenshot 2026-03-03 at 08 27 34

@bgrgicak

bgrgicak commented Mar 3, 2026

Copy link
Copy Markdown
Collaborator Author

How far could we get with the devtools MCP and the exposed Playground API?

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 window.playgroundSites.

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.

@bgrgicak

bgrgicak commented Mar 3, 2026

Copy link
Copy Markdown
Collaborator Author

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.

@brandonpayton

Copy link
Copy Markdown
Member

Thanks for all this discussion, @bgrgicak!

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.

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 experimental-mcp? If we did that, maybe we could simply drop traditional MCP support if WebMCP gains adoption.

Yes, any context can be the source of prompt injection. It's not specific to MCP servers or Playground MCP.

Should we disable the use of arbitrary blueprints by default when the MCP is enabled?

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 window.playgroundSites.

This seems like a great thing to add to the global API, whether added here or in another PR.

The only real advantage of the Playground MCP is that it's a purpose-built tool

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.

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.

Maybe we can just support both and try to measure which ones folks are using.

Does this sound reasonable?

@bgrgicak
bgrgicak force-pushed the add/playground-website-mcp-server branch from 5c3bd15 to 40515af Compare March 5, 2026 10:26
@bgrgicak

bgrgicak commented Mar 5, 2026

Copy link
Copy Markdown
Collaborator Author

Would there be any value in making this an experimental feature with a query arg like experimental-mcp? If we did that, maybe we could simply drop traditional MCP support if WebMCP gains adoption.

I added a mcp query argument that enables the server bridge. I would prefer to call it mcp instead of experimental-mcp because the server works really well and I don't want to leave the impression of something unstable.

Should we disable the use of arbitrary blueprints by default when the MCP is enabled?

I'm not sure, it reduces the attack surface a bit, but you can still install plugins, and open existing saved sites.
The main difference is that I can't share a /?mcp&bluerpint-url=INSTALL-PROMPT-INJECTION link with you.
Even if I shared it with you, it would still require you to use the MCP.

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 will disable it.

This seems like a great thing to add to the global API, whether added here or in another PR.

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.

Maybe we can just support both and try to measure which ones folks are using.

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.

@bgrgicak
bgrgicak requested a review from brandonpayton March 5, 2026 13:35
@bgrgicak

bgrgicak commented Mar 5, 2026

Copy link
Copy Markdown
Collaborator Author

@brandonpayton this is now ready for review.

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 window.playgroundSites.

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 brandonpayton left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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`. |

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was coming to request the ability to set the port and found you'd already added it. 🙌 🙇

Comment thread packages/vite-extensions/vite-external-modules.ts
Comment on lines +130 to +135
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.`
);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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');

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AFAIK, require() throws if it fails, so null coalescing will not work here.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we just use 0 here so the system picks a free port?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 },
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need a test for the recursive version of this command?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done 7746eb0

activeInTabs: string[];
}

export const DEFAULT_WS_PORT = 7999;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

Comment on lines +62 to +66
ws.addEventListener('open', () => {
previousSitesSerialized = '';
sendSitesRegistration(ws!);
config.onConnect?.();
});

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done b0778c8

@bgrgicak

bgrgicak commented Mar 9, 2026

Copy link
Copy Markdown
Collaborator Author

Regarding ports and requiring a secret, I have a draft implementation that does a few things to make the MCP server more secure:

  • The socket server now requires a token to accept new connections
  • The MCP client requests the token from a API endpoint hosted on the MCP serve
    • The API endpoint accepts requests only from the Playground.WordPress.net and localhost/127.0.0.1 origins
  • There's no more default port, it's always dynamic

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.

@brandonpayton

Copy link
Copy Markdown
Member

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.

Awesome!

@bgrgicak

Copy link
Copy Markdown
Collaborator Author

This is ready to be merged. I tested it locally using npm run local-package-repository, all tools work in Claude.
The security changes are documented in the PR description.

Once CI tests pass, I will merge it and publish to NPM.

@bgrgicak
bgrgicak merged commit 962e533 into trunk Mar 10, 2026
82 of 85 checks passed
@bgrgicak
bgrgicak deleted the add/playground-website-mcp-server branch March 10, 2026 12:34
@brandonpayton

brandonpayton commented Mar 10, 2026 •

Copy link
Copy Markdown
Member

This is great, @bgrgicak! Requiring a secret token and using random ports really tightened this up. Thank you!

bgrgicak added a commit that referenced this pull request Mar 31, 2026
## 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants