*/ private const TOOL_NAME_ALIASES = [ 'webcalendar/list-events' => 'webcalendar_list_events', 'webcalendar/get-event' => 'webcalendar_get_event', 'webcalendar/create-event' => 'webcalendar_create_event', 'webcalendar/update-event' => 'webcalendar_update_event', 'webcalendar/delete-event' => 'webcalendar_delete_event', 'webcalendar/list-categories' => 'webcalendar_list_categories', 'webcalendar/get-calendar-info' => 'webcalendar_get_calendar_info', ]; /** * Abilities instance for executing tools. * * @var Abilities */ private Abilities $abilities; /** * Constructor. * * @param Abilities $abilities The abilities instance providing tool implementations. * @psalm-suppress PossiblyUnusedMethod -- constructed via McpEndpoint when MCP is enabled */ public function __construct( Abilities $abilities ) { $this->abilities = $abilities; } /** * Handle a JSON-RPC 2.0 request. * * @param array $request The parsed JSON-RPC request. * @return array|null Response array, or null for notifications. */ public function handle( array $request ): ?array { $jsonrpc = $request['jsonrpc'] ?? ''; $method = $request['method'] ?? ''; $params = $request['params'] ?? []; $raw_id = $request['id'] ?? null; // Normalize id to int|string|null per JSON-RPC 2.0 spec. $id = is_int( $raw_id ) || is_string( $raw_id ) ? $raw_id : null; if ( $jsonrpc !== '2.0' ) { return $this->error( $id, -32600, __( 'Invalid Request: jsonrpc must be "2.0"', 'agenticdaisy-calendar' ) ); } if ( ! is_string( $method ) || $method === '' ) { return $this->error( $id, -32600, __( 'Invalid Request: method is required', 'agenticdaisy-calendar' ) ); } if ( ! is_array( $params ) ) { $params = []; } // Notifications (no id) — acknowledge silently if ( $id === null ) { return null; } return match ( $method ) { 'initialize' => $this->handle_initialize( $id, $params ), 'ping' => $this->handle_ping( $id ), 'tools/list' => $this->handle_tools_list( $id ), 'tools/call' => $this->handle_tools_call( $id, $params ), default => $this->error( $id, -32601, sprintf( /* translators: %s: method name */ __( 'Method not found: %s', 'agenticdaisy-calendar' ), $method ) ), }; } /** * Handle the initialize method. * * @psalm-suppress UnusedParam -- $params reserved for future capability negotiation. * @param string|int $id The request ID. * @param array $params The request params. * @return array */ private function handle_initialize( string|int $id, array $params ): array { return $this->result( $id, [ 'protocolVersion' => self::PROTOCOL_VERSION, 'capabilities' => [ 'tools' => new \stdClass(), ], 'serverInfo' => [ 'name' => self::SERVER_NAME, 'version' => defined( 'WEBCALENDAR_WP_VERSION' ) ? WEBCALENDAR_WP_VERSION : '1.0.0', ], ] ); } /** * Handle the ping method. * * @param string|int $id The request ID. * @return array */ private function handle_ping( string|int $id ): array { return $this->result( $id, new \stdClass() ); } /** * Handle tools/list. * * @param string|int $id The request ID. * @return array */ private function handle_tools_list( string|int $id ): array { return $this->result( $id, [ 'tools' => $this->get_tools(), ] ); } /** * Handle tools/call. * * @param string|int $id The request ID. * @param array $params The request params (name, arguments). * @return array */ private function handle_tools_call( string|int $id, array $params ): array { $tool_name = $params['name'] ?? ''; $arguments = $params['arguments'] ?? []; if ( ! is_string( $tool_name ) || $tool_name === '' ) { return $this->error( $id, -32602, __( 'Invalid params: tool name is required', 'agenticdaisy-calendar' ) ); } // Back-compat: translate legacy slash tool names to their snake_case form. if ( isset( self::TOOL_NAME_ALIASES[ $tool_name ] ) ) { $tool_name = self::TOOL_NAME_ALIASES[ $tool_name ]; } if ( ! is_array( $arguments ) ) { $arguments = []; } $tool_map = $this->get_tool_map(); if ( ! isset( $tool_map[ $tool_name ] ) ) { return $this->error( $id, -32602, sprintf( /* translators: %s: tool name */ __( 'Tool not found: %s', 'agenticdaisy-calendar' ), $tool_name ) ); } $tool_def = $tool_map[ $tool_name ]; // Read-only guard — mutating tools are rejected when read-only mode is on. // Enforced here (not just hidden from tools/list) so a client that already // knows the tool name cannot bypass the restriction. if ( $this->is_read_only() && ( $tool_def['mutating'] ?? false ) ) { return $this->error( $id, -32601, sprintf( /* translators: %s: tool name */ __( 'Tool "%s" is unavailable: the MCP server is in read-only mode, so create, update, and delete are disabled.', 'agenticdaisy-calendar' ), $tool_name ) ); } // Permission check $permission_check = $tool_def['permission_callback']; if ( ! $permission_check() ) { return $this->error( $id, -32603, __( 'Permission denied for this tool.', 'agenticdaisy-calendar' ) ); } try { $tool_result = $this->execute_tool( $tool_name, $tool_def, $arguments ); if ( is_wp_error( $tool_result ) ) { return $this->result( $id, [ 'content' => [ [ 'type' => 'text', 'text' => $tool_result->get_error_message(), ], ], 'isError' => true, ] ); } if ( isset( $tool_result['error'] ) ) { return $this->result( $id, [ 'content' => [ [ 'type' => 'text', 'text' => is_string( $tool_result['error'] ) ? $tool_result['error'] : wp_json_encode( $tool_result['error'] ), ], ], 'isError' => true, ] ); } return $this->result( $id, [ 'content' => [ [ 'type' => 'text', 'text' => (string) wp_json_encode( $tool_result, JSON_UNESCAPED_SLASHES ), ], ], ] ); } catch ( \Exception $e ) { return $this->result( $id, [ 'content' => [ [ 'type' => 'text', 'text' => __( 'Tool execution error: ', 'agenticdaisy-calendar' ) . $e->getMessage(), ], ], 'isError' => true, ] ); } } /** * Map of MCP tool name to the ability that implements it. * * Every tool has an ability; the two lists are kept in step by * `McpServerTest::test_every_tool_maps_to_a_registered_ability_name()`. * * @return array */ public static function tool_ability_map(): array { return [ 'webcalendar_list_events' => 'webcalendar/list-events', 'webcalendar_get_event' => 'webcalendar/get-event', 'webcalendar_list_users' => 'webcalendar/list-users', 'webcalendar_create_event' => 'webcalendar/create-event', 'webcalendar_update_event' => 'webcalendar/update-event', 'webcalendar_delete_event' => 'webcalendar/delete-event', 'webcalendar_list_categories' => 'webcalendar/list-categories', 'webcalendar_get_calendar_info' => 'webcalendar/get-calendar-info', ]; } /** * Execute a tool, preferring the Abilities API. * * Tool calls run through `WP_Ability::execute()` rather than calling the * `Abilities` method directly. This matters for more than tidiness: filters * on `wp_register_ability_args` wrap an ability's execute callback, and * plugins that govern agent behaviour — Agent Firewall's policy engine, for * one — rely on that wrapper being in the call path. Invoking * `$this->abilities->execute_delete_event()` reaches identical code with * the wrapper stripped off, so the guard never runs. Going through the * ability also applies its own input validation and permission check. * * Falls back to the direct callback when no ability answers: WordPress * before 6.9 has no Abilities API at all (this plugin supports 6.6+), and * registration can be filtered away. Losing governance beats breaking every * calendar tool on those installs. * * @param string $tool_name The resolved (snake_case) tool name. * @param array $tool_def The tool definition. * @param array $arguments Tool arguments. * @return array|\WP_Error */ private function execute_tool( string $tool_name, array $tool_def, array $arguments ) { $ability_name = self::tool_ability_map()[ $tool_name ] ?? null; if ( null !== $ability_name && function_exists( 'wp_get_ability' ) ) { $ability = wp_get_ability( $ability_name ); if ( null !== $ability ) { /** @var array|\WP_Error $result */ $result = $ability->execute( $arguments ); return $result; } } $execute = $tool_def['execute_callback']; if ( ! is_callable( $execute ) ) { return new \WP_Error( 'webcal_mcp_missing_executor', __( 'This tool has no executor configured.', 'agenticdaisy-calendar' ) ); } /** @var array $result */ $result = $execute( $arguments ); return $result; } /** * Get the list of available MCP tools in MCP schema format. * * @return array> */ public function get_tools(): array { $tool_map = $this->get_tool_map(); $read_only = $this->is_read_only(); $tools = []; foreach ( $tool_map as $name => $def ) { // In read-only mode, mutating tools are not advertised so agents // never attempt them (tools/call also blocks them defensively). if ( $read_only && ( $def['mutating'] ?? false ) ) { continue; } $tools[] = [ 'name' => $name, 'description' => $def['description'], 'inputSchema' => $def['input_schema'], ]; } return $tools; } /** * Names of every registered MCP tool, unfiltered. * * Unlike get_tools() this ignores read-only mode — it reports what the build * ships, so callers (e.g. the admin "Try It" diagnostic) can show the true * registered-tool count without it drifting when tools are added. * * @return array */ public function get_tool_names(): array { return array_keys( $this->get_tool_map() ); } /** * Whether the MCP server is restricted to read-only tools. * * When enabled, the mutating tools (create/update/delete) are hidden from * tools/list and rejected by tools/call. Site owners opt in via the AI * settings; the value is filterable for programmatic overrides. * * @return bool */ private function is_read_only(): bool { $raw = get_option( 'webcal_mcp_read_only', 'no' ); $enabled = is_scalar( $raw ) && (string) $raw === 'yes'; /** * Filter whether the MCP server operates in read-only mode. * * @param bool $enabled True to allow only read tools (list/get), false to allow writes. */ return (bool) apply_filters( 'webcal_mcp_read_only', $enabled ); } /** * Get the internal tool map with callbacks and permissions. * * @return array, execute_callback: callable, permission_callback: callable, mutating?: bool}> */ private function get_tool_map(): array { return [ 'webcalendar_list_events' => [ 'description' => __( 'Retrieve calendar events within a date range, optionally filtered by user or category.', 'agenticdaisy-calendar' ), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'start_date' => [ 'type' => 'string', 'description' => __( 'Start date in ISO 8601 format (e.g. 2026-03-01).', 'agenticdaisy-calendar' ), ], 'end_date' => [ 'type' => 'string', 'description' => __( 'End date in ISO 8601 format (e.g. 2026-03-31).', 'agenticdaisy-calendar' ), ], 'user' => [ 'type' => 'string', 'description' => __( 'Filter by WordPress user login.', 'agenticdaisy-calendar' ), ], 'category_id' => [ 'type' => 'integer', 'description' => __( 'Filter by category ID.', 'agenticdaisy-calendar' ), ], 'max_results' => [ 'type' => 'integer', 'description' => __( 'Maximum results (default 50, max 200).', 'agenticdaisy-calendar' ), ], ], 'required' => [ 'start_date', 'end_date' ], ], 'execute_callback' => [ $this->abilities, 'execute_list_events' ], 'permission_callback' => fn() => current_user_can( 'webcal_can_view' ), ], 'webcalendar_get_event' => [ 'description' => __( 'Retrieve full details of a single calendar event by its ID.', 'agenticdaisy-calendar' ), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'event_id' => [ 'type' => 'integer', 'description' => __( 'The event ID.', 'agenticdaisy-calendar' ), ], ], 'required' => [ 'event_id' ], ], 'execute_callback' => [ $this->abilities, 'execute_get_event' ], 'permission_callback' => fn() => current_user_can( 'webcal_can_view' ), ], 'webcalendar_list_users' => [ 'description' => __( 'List calendar users and shared/remote calendars (login, display name, and type) so events can be filtered by user and participants can be specified by login.', 'agenticdaisy-calendar' ), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'search' => [ 'type' => 'string', 'description' => __( 'Optional search term to filter by login, display name, or email.', 'agenticdaisy-calendar' ), ], ], ], 'execute_callback' => [ $this->abilities, 'execute_list_users' ], 'permission_callback' => fn() => current_user_can( 'webcal_can_view' ), ], 'webcalendar_create_event' => [ 'description' => __( 'Create a new calendar event with title, date/time, location, and optional recurrence.', 'agenticdaisy-calendar' ), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'title' => [ 'type' => 'string', 'description' => __( 'Event title.', 'agenticdaisy-calendar' ), ], 'start' => [ 'type' => 'string', 'description' => __( 'Start date/time in ISO 8601 format.', 'agenticdaisy-calendar' ), ], 'duration' => [ 'type' => 'integer', 'description' => __( 'Duration in minutes (default 60).', 'agenticdaisy-calendar' ), ], 'all_day' => [ 'type' => 'boolean', 'description' => __( 'Whether this is an all-day event.', 'agenticdaisy-calendar' ), ], 'description' => [ 'type' => 'string', 'description' => __( 'Event description.', 'agenticdaisy-calendar' ), ], 'location' => [ 'type' => 'string', 'description' => __( 'Event location.', 'agenticdaisy-calendar' ), ], 'access' => [ 'type' => 'string', 'description' => __( 'Access level: P, C, or R.', 'agenticdaisy-calendar' ), 'enum' => [ 'P', 'C', 'R' ], ], 'rrule' => [ 'type' => 'string', 'description' => __( 'RFC 5545 recurrence rule.', 'agenticdaisy-calendar' ), ], 'category_id' => [ 'type' => 'integer', 'description' => __( 'Category ID to assign.', 'agenticdaisy-calendar' ), ], 'participants' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ], 'description' => __( 'Participant user logins.', 'agenticdaisy-calendar' ), ], ], 'required' => [ 'title', 'start' ], ], 'execute_callback' => [ $this->abilities, 'execute_create_event' ], 'permission_callback' => fn() => current_user_can( 'webcal_can_edit' ), 'mutating' => true, ], 'webcalendar_update_event' => [ 'description' => __( 'Update an existing calendar event. Only provided fields are changed.', 'agenticdaisy-calendar' ), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'event_id' => [ 'type' => 'integer', 'description' => __( 'The event ID to update.', 'agenticdaisy-calendar' ), ], 'title' => [ 'type' => 'string', 'description' => __( 'New event title.', 'agenticdaisy-calendar' ), ], 'start' => [ 'type' => 'string', 'description' => __( 'New start date/time.', 'agenticdaisy-calendar' ), ], 'duration' => [ 'type' => 'integer', 'description' => __( 'New duration in minutes.', 'agenticdaisy-calendar' ), ], 'all_day' => [ 'type' => 'boolean', 'description' => __( 'Whether this is an all-day event.', 'agenticdaisy-calendar' ), ], 'description' => [ 'type' => 'string', 'description' => __( 'New event description.', 'agenticdaisy-calendar' ), ], 'location' => [ 'type' => 'string', 'description' => __( 'New event location.', 'agenticdaisy-calendar' ), ], 'access' => [ 'type' => 'string', 'description' => __( 'Access level: P, C, or R.', 'agenticdaisy-calendar' ), 'enum' => [ 'P', 'C', 'R' ], ], 'category_id' => [ 'type' => 'integer', 'description' => __( 'Category ID (0 to remove).', 'agenticdaisy-calendar' ), ], 'participants' => [ 'type' => 'array', 'items' => [ 'type' => 'string' ], 'description' => __( 'Participant user logins.', 'agenticdaisy-calendar' ), ], ], 'required' => [ 'event_id' ], ], 'execute_callback' => [ $this->abilities, 'execute_update_event' ], 'permission_callback' => fn() => current_user_can( 'webcal_can_edit' ), 'mutating' => true, ], 'webcalendar_delete_event' => [ 'description' => __( 'Permanently delete a calendar event by ID.', 'agenticdaisy-calendar' ), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'event_id' => [ 'type' => 'integer', 'description' => __( 'The event ID to delete.', 'agenticdaisy-calendar' ), ], ], 'required' => [ 'event_id' ], ], 'execute_callback' => [ $this->abilities, 'execute_delete_event' ], 'permission_callback' => fn() => current_user_can( 'webcal_can_delete' ), 'mutating' => true, ], 'webcalendar_list_categories' => [ 'description' => __( 'Retrieve all event categories with their names and colors.', 'agenticdaisy-calendar' ), 'input_schema' => [ 'type' => 'object', 'properties' => new \stdClass(), ], 'execute_callback' => [ $this->abilities, 'execute_list_categories' ], 'permission_callback' => fn() => current_user_can( 'webcal_can_view' ), ], 'webcalendar_get_calendar_info' => [ 'description' => __( 'Get general calendar information including total events, categories, and configuration.', 'agenticdaisy-calendar' ), 'input_schema' => [ 'type' => 'object', 'properties' => new \stdClass(), ], 'execute_callback' => [ $this->abilities, 'execute_get_calendar_info' ], 'permission_callback' => fn() => current_user_can( 'webcal_can_view' ), ], ]; } /** * Build a JSON-RPC 2.0 success response. * * @param string|int|null $id The request ID. * @param mixed $result The result value. * @return array */ private function result( string|int|null $id, mixed $result ): array { return [ 'jsonrpc' => '2.0', 'id' => $id, 'result' => $result, ]; } /** * Build a JSON-RPC 2.0 error response. * * @param string|int|null $id The request ID. * @param int $code The error code. * @param string $message The error message. * @return array */ private function error( string|int|null $id, int $code, string $message ): array { return [ 'jsonrpc' => '2.0', 'id' => $id, 'error' => [ 'code' => $code, 'message' => $message, ], ]; } }