delete_event` arrive as the same POST to * the same route, which is why the REST descriptor saw both as one opaque * "create unknown" (the measured 2026-08-01 silent policy failure). */ final class McpTransport { /** * JSON-RPC error code for a policy denial. */ public const CODE_DENIED = -32001; /** * JSON-RPC error code for an action held for human approval. */ public const CODE_PENDING = -32002; /** * Largest batch evaluated call-by-call. Modern MCP has dropped JSON-RPC * batching entirely and legitimate legacy batches are small; an * envelope beyond this is treated as malformed and fails safe, rather * than spending thousands of evaluations and ledger rows on one * request. */ public const MAX_BATCH = 20; /** * Constructor. * * @param InterceptionPipeline $pipeline Shared interception pipeline. */ public function __construct( private readonly InterceptionPipeline $pipeline ) { } /** * Whether a request carries an MCP envelope: binding headers first, * body shape as the fallback for pre-2026 transports. * * @param \WP_REST_Request $request The request. */ public static function is_mcp( $request ): bool { if ( null !== $request->get_header( 'Mcp-Method' ) || null !== $request->get_header( 'Mcp-Protocol-Version' ) ) { return true; } return null !== self::decode( (string) $request->get_body() ); } /** * Evaluate every call in the envelope; the envelope itself is never * ledgered as an action of its own. * * @param Agent $agent Acting agent. * @param \WP_REST_Request $request The request. * @return \WP_REST_Response|null Null to continue dispatch; a JSON-RPC * error response to halt it. */ public function process( Agent $agent, $request ) { $calls = self::decode( (string) $request->get_body() ); if ( null === $calls || count( $calls ) > self::MAX_BATCH ) { // An MCP signal over a body that does not parse (or an envelope // too large to be legitimate): evaluate one fail-safe unknown // action rather than waving the request through. return $this->halt_unless_allowed( $agent, ActionDescriptor::from_mcp( array() ), null ); } $header_method = $request->get_header( 'Mcp-Method' ); $allowed = array(); foreach ( $calls as $call ) { $id = isset( $call['id'] ) && is_scalar( $call['id'] ) ? $call['id'] : null; // The binding lets servers reject header/body mismatches; we must // not be gamed by one either. A header claiming a read over a body // that says otherwise classifies from neither and fails safe. $mismatch = is_string( $header_method ) && 1 === count( $calls ) && ( ! isset( $call['method'] ) || $call['method'] !== $header_method ); $action = ActionDescriptor::from_mcp( $mismatch ? array() : $call ); $tool = $mismatch ? null : self::tool_call( $call ); // The MCP adapter's discovery tools only describe abilities. if ( null !== $tool && McpAdapterCall::is_discovery( $tool[0] ) ) { continue; } // Protocol plumbing passes unevaluated, exactly as GETs do at // the REST door. A tool only its name calls a read does too, // except for an agent with administrator rights (AF-25.15): for // it, `run_sql_query` reading as a read is no reason to trust it, // so the guard judges it. if ( 'read' === $action->action_cat && ( null === $action->ability_name || ! user_can( $agent->wp_user_id, 'manage_options' ) ) ) { continue; } // A tool backed by a registered ability belongs to the ability // door: a compliant server executes it through // WP_Ability::execute(), where AbilityInterceptor governs it // with full ability semantics - evaluating here too would // duplicate ledger rows and spend rate budget twice. A server // bypassing the wrapper leaves the request marker unset, so the // effect backstop still catches the mutation. if ( null !== $action->ability_name && self::backed_by_ability( $action->ability_name ) ) { continue; } // The adapter's execute tool, where no ability backs it (WooCommerce's // bundled adapter before 11.3): judged as the ability it names, so // rules, classifiers and facts see that ability and approving the // hold runs it (AF-25.17). $unwrapped = null === $tool ? null : McpAdapterCall::unwrap( $tool[0], $tool[1] ); if ( null !== $unwrapped ) { $action = $unwrapped; } $outcome = $this->pipeline->process( $agent, $action ); $halt = $this->halt_response( $outcome, $id ); if ( null !== $halt ) { // Siblings already ledgered as allowed never executed - the // whole envelope is being refused. Say so, or the audit // trail claims writes happened that did not. foreach ( $allowed as $ledger_id ) { $this->pipeline->record_result( $ledger_id, array( 'executed' => false, 'note' => 'envelope halted by a sibling call', ) ); } return $halt; } $allowed[] = $outcome->ledger_id; } return null; } /** * A tools/call frame's tool name and arguments, or null for any other * frame. * * @param array $call One JSON-RPC call. * @return array{0: string, 1: array}|null */ private static function tool_call( array $call ): ?array { $params = isset( $call['params'] ) && is_array( $call['params'] ) ? $call['params'] : array(); if ( 'tools/call' !== ( $call['method'] ?? null ) || ! is_string( $params['name'] ?? null ) ) { return null; } return array( $params['name'], is_array( $params['arguments'] ?? null ) ? $params['arguments'] : array() ); } /** * Whether a tool name resolves to a registered ability, restoring the * namespace slash that MCP tool-name rules flatten away * (webcalendar-delete-event or webcalendar--delete-event for * webcalendar/delete-event). * * @param string $name Tool name as it arrived. */ private static function backed_by_ability( string $name ): bool { $candidates = array_unique( array( $name, (string) preg_replace( '/-/', '/', $name, 1 ), str_replace( '--', '/', $name ), ) ); foreach ( $candidates as $candidate ) { if ( wp_has_ability( $candidate ) ) { return true; } } return false; } /** * Run one call through the pipeline; translate any blocking outcome into * a JSON-RPC error an MCP client can interpret. * * @param Agent $agent Acting agent. * @param ActionDescriptor $action Per-call descriptor. * @param int|string|bool|float|null $id JSON-RPC id of the offending call. * @return \WP_REST_Response|null */ private function halt_unless_allowed( Agent $agent, ActionDescriptor $action, $id ) { return $this->halt_response( $this->pipeline->process( $agent, $action ), $id ); } /** * Translate a blocking outcome into a JSON-RPC error, or null when the * call may execute. * * @param InterceptionOutcome $outcome Pipeline outcome. * @param int|string|bool|float|null $id JSON-RPC id of the offending call. * @return \WP_REST_Response|null */ private function halt_response( InterceptionOutcome $outcome, $id ) { if ( Decision::DENY === $outcome->decision ) { $data = array( 'reason' => $outcome->reason ); if ( null !== $outcome->retry_after ) { $data['retry_after'] = $outcome->retry_after; } return self::json_rpc_error( $id, self::CODE_DENIED, __( 'Denied by Agent Firewall policy. The call did not execute.', 'agentic-daisy-ai-agent-firewall' ), $data ); } if ( ! $outcome->may_execute() ) { return self::json_rpc_error( $id, self::CODE_PENDING, __( 'Held for human approval by Agent Firewall. The call has not executed; poll the pending-status endpoint with this action id rather than resubmitting.', 'agentic-daisy-ai-agent-firewall' ), array( 'status' => 'pending_approval', 'action_id' => $outcome->pending_id, 'retryable' => false, ) ); } return null; } /** * A JSON-RPC error as the transport expects one: HTTP 200 with an error * object, not a bare REST error an MCP client cannot interpret. * * @param int|string|bool|float|null $id Offending call's id. * @param int $code JSON-RPC error code. * @param string $message Human-readable message. * @param array $data Error data payload. */ private static function json_rpc_error( $id, int $code, string $message, array $data ): \WP_REST_Response { return new \WP_REST_Response( array( 'jsonrpc' => '2.0', 'id' => $id, 'error' => array( 'code' => $code, 'message' => $message, 'data' => $data, ), ), 200 ); } /** * Whether an envelope carries nothing but protocol plumbing. * * `initialize`, `ping`, the `tools/list` family of discovery calls and every * `notifications/*` frame mutate nothing. process() already lets them * past unevaluated on the agent path; this is the same judgment made * available to the untracked door, which had no way to ask and so * recorded each one as an opaque REST "create unknown". * * That mattered more than a mislabeled row: an MCP client with no * agent identity is typically a health monitor, and one probing every * 30s wrote ~5,800 ledger rows a day on a live site (2026-09-16), * drowning the findings the ledger exists to surface. * * Fails safe in every direction - an unparsable body, an oversized * batch, or a header and body that disagree all answer false and keep * the caller's existing behavior, because none of them prove the * request is harmless. * * @param \WP_REST_Request $request The request. */ public static function is_protocol_only( $request ): bool { if ( ! self::is_mcp( $request ) ) { return false; } $calls = self::decode( (string) $request->get_body() ); if ( null === $calls || array() === $calls || count( $calls ) > self::MAX_BATCH ) { return false; } $header_method = $request->get_header( 'Mcp-Method' ); foreach ( $calls as $call ) { $mismatch = is_string( $header_method ) && 1 === count( $calls ) && ( ! isset( $call['method'] ) || $call['method'] !== $header_method ); if ( $mismatch ) { return false; } if ( 'read' !== ActionDescriptor::from_mcp( $call )->action_cat ) { return false; } } return true; } /** * Decode a body into its JSON-RPC calls. * * @param string $body Raw request body. * @return list>|null The calls, or null when the * body is not JSON-RPC shaped. */ private static function decode( string $body ): ?array { $decoded = json_decode( $body, true ); if ( self::is_call( $decoded ) ) { /** @var array $decoded */ return array( $decoded ); } if ( is_array( $decoded ) && array() !== $decoded && array_is_list( $decoded ) ) { foreach ( $decoded as $item ) { if ( ! self::is_call( $item ) ) { return null; } } /** @var list> $decoded */ return $decoded; } return null; } /** * Whether a decoded value is one JSON-RPC 2.0 request object. * * @param mixed $value Decoded JSON value. */ private static function is_call( $value ): bool { return is_array( $value ) && isset( $value['jsonrpc'] ) && '2.0' === $value['jsonrpc'] && isset( $value['method'] ) && is_string( $value['method'] ); } }