maximus Posted August 25 Posted August 25 Hi everyone, I’m releasing MCP Server, a module that lets external AI assistants and automation tools work with your ProcessWire site through specific operations exposed by installed modules—for example, reading discussions, checking orders or updating shipment tracking. AgentTools helps you build and maintain a site. MCP Server lets external tools use the application you’ve built. They can complement each other. Each module decides what an assistant can read or change. MCP Server handles authentication, access scopes and audit logs; installing it does not give an AI unrestricted access to your site. GitHub and documentation Feedback is welcome! 6 1
maximus Posted August 25 Author Posted August 25 For module authors, adding MCP support does not require implementing the transport or authentication layer. An existing module only needs to opt in and expose bounded tools. First, add the provider flag to the module metadata: public static function getModuleInfo(): array { return [ 'title' => 'My Module', 'version' => 100, 'mcpProvider' => true, ]; } Then add the provider contract: public function mcpProviderInfo(): array { return [ 'name' => 'my-module', 'title' => 'My Module', 'version' => (string) self::getModuleInfo()['version'], ]; } public function mcpTools(): array { return [[ 'name' => 'my_module_status', 'title' => 'My Module status', 'description' => 'Return the current module readiness state.', 'handler' => [$this, 'mcpModuleStatus'], 'scope' => 'read', 'read_only' => true, 'destructive' => false, 'idempotent' => true, 'open_world' => false, 'input_schema' => [ 'type' => 'object', 'properties' => [ 'include_details' => [ 'type' => 'boolean', ], ], 'additionalProperties' => false, ], ]]; } public function mcpModuleStatus(bool $include_details = false): array { $result = [ 'ready' => true, 'module' => 'my-module', ]; if($include_details) { $result['version'] = (string) self::getModuleInfo()['version']; } return $result; } After refreshing the ProcessWire module cache, MCP Server discovers the provider automatically. The exposed tool receives the configured installation namespace, so my_module_status may become example_my_module_status. A few rules are important: Schema property names must match the PHP handler parameters. Inputs and result sets must always be bounded. The owning module remains responsible for permissions and validation. Use read, draft, publish or admin as the tool scope. Describe read_only, destructive, idempotent and open_world accurately. Write tools should require explicit confirmation and return stable identifiers. Expected domain failures can be reported with WireException. Never expose generic PHP, SQL, shell or filesystem access. The complete provider contract is documented here: https://github.com/mxmsmnv/McpServer/blob/main/API.md 2
MrSnoozles Posted August 27 Posted August 27 (edited) Hi @maximus From the module description I don't understand what the MCP server does. Is it used to get information? Can it be used to modify pages? Can it edit fields / templates? It sounds like it has a lot of cool technical features, but when should I install this module? And how does it compare to AgentTools from Ryan? Edited August 27 by MrSnoozles
maximus Posted August 27 Author Posted August 27 Here’s a more concrete example based on an integration I’m working on for Mercato, a ProcessWire-native commerce module. Mercato already has internal services for orders, payments, inventory, fulfilment, shipping labels, tracking, refunds and transactional email. I’m planning to expose selected Mercato operations through an McpServer provider, such as: mercato.get_order mercato.list_orders_to_fulfil mercato.verify_payment mercato.create_shipment mercato.purchase_shipping_label mercato.update_tracking mercato.send_order_email Any MCP-compatible bot, Codex instance or automation platform such as n8n could then operate the store through these tools. For example, when a paid order appears, an automation could verify the payment with the remote provider, check variant inventory, create the shipment, purchase a shipping label, save the tracking information, advance fulfilment and notify the customer. If the payment, stock or shipping data does not match, it could stop the workflow and report the exception to a human instead of silently changing the order. The important distinction is that McpServer is not the workflow engine itself. The bot, agent or n8n controls the sequence. McpServer provides the authenticated, scoped and audited capability layer, while Mercato performs the actual business logic. It also does not have to expose generic operations such as “edit any page” or “modify any template”. A provider can expose only narrowly defined business actions. Each client can have its own credentials and scopes, so one integration might only read orders, another may fulfil them, while refunds or payment reconciliation remain separately protected and may require explicit confirmation. That is how I see the difference from AgentTools: AgentTools is primarily for trusted local development and site engineering. Its CLI/Engineer can work broadly with ProcessWire structure and code, while its current MCP tools are read-only. McpServer is for controlled remote operation of an already deployed application: authenticated HTTP access, per-client scopes, rate limits and audit logs, with each module deciding exactly what external agents are allowed to read or change. So yes, McpServer can be used both to retrieve information and to modify data—but only through explicit operations exposed by installed providers. It does not automatically give an AI unrestricted access to ProcessWire pages, fields or templates. 6
AndZyk Posted yesterday at 01:13 PM Posted yesterday at 01:13 PM (edited) Hi @maximus, thank you for this module. My Claude has some improvements for your module and wanted me to tell you this. I don't know if they are actually helpful. 😀 Quote Thanks for McpServer – we're using it to expose our internal ProcessWire application to Claude and the Hermes agent. The provider contract worked well for us: requestClientContext() lets us map each client to a ProcessWire user, and a handler that takes Mcp\Server\RequestContext lets us wrap our existing read-only tools generically (their argument names differ per tool, so named handler parameters didn't fit). Some feedback from setting it up (1.0.3, ProcessWire 3.0.274, PHP 8.5): Authentication cost grows with the number of clients. authenticate() loops over all enabled clients and calls password_verify() for each until one matches. On PHP 8.4+ PASSWORD_DEFAULT is bcrypt with cost 12, which takes about one second per verification on our machine. With 15–20 clients (one per person and agent, as recommended), a single tool call could spend 10+ seconds just authenticating. We patched it locally to skip clients whose stored prefix doesn't match the start of the presented token, so only one hash is checked: if(!empty($client['prefix']) && !str_starts_with($raw, (string) $client['prefix'])) continue; It would be great to have this upstream. You might also consider whether bcrypt is needed at all here: the tokens are 256-bit random values, so a keyed HMAC-SHA256 (with the userAuthSalt pepper you already use) compared with hash_equals() would be just as safe against offline guessing. It would also remove the remaining ~1 second per request. Admin "Tools" workspace: /processwire/setup/mcp-server/tools/ returns "Unrecognized path" for us, even though the README mentions a Tools workspace. The tools are visible under Providers, so maybe it's only a missing route or a documentation mismatch. Documentation: a short note on handlers that receive the raw argument array (via RequestContext) would help others who adapt existing tool sets. We'll test the Claude connector and the Hermes agent against our production server next and are happy to report back how compatible they are. Thanks again! Regards, Andreas Edited yesterday at 01:13 PM by AndZyk 1
maximus Posted 7 hours ago Author Posted 7 hours ago Hi Andreas, Thanks for testing the module and sharing these useful observations! I’ve released MCP Server 1.0.4 with prefix filtering before password verification, the missing Tools workspace, and documentation for RequestContext handlers with an example. I’ve also added regression tests for these cases. The existing credential hash format is preserved, so there’s no need to replace your tokens. Thanks again for trying it with Claude and Hermes! 1
MrSnoozles Posted 4 hours ago Posted 4 hours ago (edited) Hi @maximus There's a security issue where a key with only read permissions can still call all tools. Here's a handoff for Claude: Quote # Scope check bypass via JSON-RPC batch requests ## Affected version McpServer 1.0.4 (release zip, bundled `mcp/sdk` v0.8.0). Earlier 1.x releases not tested. ## Setup - ProcessWire 3.0.255, PHP 8.2, nginx + PHP-FPM (local DDEV environment) - Endpoint enabled at `/mcp/` - One client with scope `read` - One provider module exposing a single tool with scope `publish`: ```php class McpScopeProbe extends WireData implements Module { public static function getModuleInfo() { return ['title' => 'McpScopeProbe', 'version' => 1, 'mcpProvider' => true]; } public function mcpProviderInfo(): array { return ['name' => 'scope-probe', 'title' => 'Scope probe', 'version' => '1']; } public function mcpTools(): array { return [['name' => 'probe_publish', 'title' => 'Probe publish', 'description' => 'Test tool requiring publish scope.', 'handler' => [$this, 'probePublish'], 'scope' => 'publish', 'read_only' => false]]; } public function probePublish(): array { $this->wire('log')->save('mcp-scope-probe', 'publish tool executed'); return ['executed' => true]; } } ``` ## Summary `McpEndpointTrait::emitEndpointResponse()` checks the tool's scope and writes the audit entry before it passes the request to the SDK. It finds the called tool with `requestToolName()`, which reads `method` and `params.name` from a single JSON object only. If the body is a JSON array (a JSON-RPC batch), `requestToolName()` returns `''`, so the unknown-tool check, the scope check and the audit entry are all skipped. `mcp/sdk` 0.8.0 still accepts batches for sessions using protocol version `2025-03-26` or older (`InboundClassifier::classifyBatch()` only rejects batches for the modern protocol revision) and runs every `tools/call` in them. The client picks the protocol version in `initialize`, so any client can opt into batching. ## Impact - Any valid bearer token, including a `read`-only one, can call every registered tool, whatever scope it requires (`draft`, `publish`, `admin`). - Calls made this way never reach the audit table. - The per-client rate limit counts HTTP requests, so one batch can carry several tool calls (the SDK's default batch limit is 100). A valid token is still required; unauthenticated requests are rejected as expected. ## Reproduction `$TOKEN` is the bearer token of the `read`-only client; tool names carry the namespace prefix `semes_`. ```bash U=https://example.test/mcp/ H=(-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" -H "Accept: application/json, text/event-stream") # 1. Handshake with an older protocol revision; note the Mcp-Session-Id response header curl -si "${H[@]}" -X POST $U -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-03-26","capabilities":{},"clientInfo":{"name":"probe","version":"1"}}}' curl -s "${H[@]}" -H "Mcp-Session-Id: $SID" -X POST $U -d '{"jsonrpc":"2.0","method":"notifications/initialized"}' # 2. Single call: correctly denied curl -s "${H[@]}" -H "Mcp-Session-Id: $SID" -X POST $U \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"semes_probe_publish","arguments":{}}}' # 3. Same call wrapped in an array: executed curl -s "${H[@]}" -H "Mcp-Session-Id: $SID" -X POST $U \ -d '[{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"semes_probe_publish","arguments":{}}}]' ``` ## Observed result Step 2, HTTP 403: ```json {"error":"insufficient_scope","message":"This client is not allowed to call the requested tool."} ``` Step 3, HTTP 200, and the tool ran (its log entry `publish tool executed` was written): ```json {"jsonrpc":"2.0","id":3,"result":{"content":[{"type":"text","text":"{\n \"executed\": true\n}"}],"isError":false,"structuredContent":{"executed":true}}} ``` `mcp_server_audit` afterwards holds only the denied call from step 2: ``` request_id client_id tool_name scope status 2 <client id> semes_probe_publish publish denied ``` ## Suggested fix 1. Enforce scope where every tool call passes, independent of how the request body is shaped: `McpServerReferenceHandler::handle()` receives a `Mcp\Capability\Registry\ToolReference` for tool calls, and `$reference->tool->name` identifies the tool. Checking the active client's scope there (and writing the audit entry there) covers single calls and batches alike. The handler needs access to the `McpServer` instance, e.g. through its constructor. 2. At the gateway, either reject JSON array bodies outright (the current MCP revision removed batching) or apply the unknown-tool check, scope check and audit to every element of a batch. 3. Add a regression test: a batch call to a `publish` tool with a `read` token is denied and audited. Edited 2 hours ago by MrSnoozles
maximus Posted 1 hour ago Author Posted 1 hour ago Hi, Thank you for the detailed report and clear reproduction steps. I confirmed the issue and have released MCP Server 1.0.5 with the fix. Scopes are now checked immediately before each tool invocation, with audit recording at that boundary. JSON-RPC batches are rejected for all protocol versions; single-message legacy clients remain supported. I’ve added regression tests covering the reported bypass and audit behavior. Thanks again for catching this! 1
Recommended Posts
Create an account or sign in to comment
You need to be a member in order to leave a comment
Create an account
Sign up for a new account in our community. It's easy!
Register a new accountSign in
Already have an account? Sign in here.
Sign In Now