*/ 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', 'webcalendar_list_venues' => 'webcalendar/list-venues', 'webcalendar_list_organizers' => 'webcalendar/list-organizers', 'webcalendar_describe_schema' => 'webcalendar/describe-schema', 'webcalendar_get_event_registration' => 'webcalendar/get-event-registration', 'webcalendar_list_attendees' => 'webcalendar/list-attendees', ]; } /** * 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 behavior — 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() ); } /** * Every registered MCP tool with its description, required inputs and * whether it writes, unfiltered by read-only mode. * * The admin AI page renders its tools table from this, so admins read the * same descriptions that tools/list gives assistants instead of a copy * that drifts from them. * * @return array, mutating: bool}> */ public function get_tool_catalog() : array { $catalog = []; foreach ( $this->get_tool_map() as $name => $def ) { $required = $def['input_schema']['required'] ?? []; $catalog[] = [ 'name' => $name, 'description' => $def['description'], 'required' => ( is_array( $required ) ? array_values( array_filter( $required, 'is_string' ) ) : [] ), 'mutating' => $def['mutating'] ?? false, ]; } return $catalog; } /** * 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 { $map = [ 'webcalendar_list_events' => [ 'description' => __( 'Retrieve the events that occur within a date range, sorted by start time. Repeating events are expanded: each occurrence in the range is its own entry, and every occurrence carries the ID of the whole series. Only events the connected user may see are returned. `total` counts all matches; when it exceeds the number of entries returned, narrow the date range to see the rest (there is no offset parameter).', '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). At most about a year after start_date, and within ten years from now.', 'agenticdaisy-calendar' ), ], 'user' => [ 'type' => 'string', 'description' => __( 'Filter by WordPress user login. Omit for all users.', 'agenticdaisy-calendar' ), ], 'category_id' => [ 'type' => 'integer', 'description' => __( 'Filter by category ID.', 'agenticdaisy-calendar' ), ], 'max_results' => [ 'type' => 'integer', 'description' => __( 'Maximum number of entries to return (default 50, max 200).', 'agenticdaisy-calendar' ), ], ], 'required' => ['start_date', 'end_date'], ], 'execute_callback' => [$this->abilities, 'execute_list_events'], 'permission_callback' => fn(): bool => current_user_can( 'webcal_can_view' ), ], 'webcalendar_get_event' => [ 'description' => __( 'Retrieve full details of a single event by ID, including its description, participants and attachments. For a repeating event this returns the series (its first start date and its rrule), not one occurrence. A missing ID and an event the connected user may not see both return "Event not found."', '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(): bool => 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(): bool => current_user_can( 'webcal_can_view' ), ], 'webcalendar_create_event' => [ 'description' => __( 'Create a calendar event. The connected user becomes its owner and is always added as a participant. Pass `rrule` to make it repeat. Participant logins that match neither a WordPress user nor a shared or remote calendar are skipped and returned in `invalid_participants`; the event is still created.', '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 (e.g. 2026-03-15T14:00:00). Times without a timezone offset are interpreted as the site\'s configured timezone. A date-only value (e.g. 2026-03-15) creates an all-day event.', 'agenticdaisy-calendar' ), ], 'duration' => [ 'type' => 'integer', 'description' => __( 'Duration in minutes (default 60). Ignored for all-day events.', 'agenticdaisy-calendar' ), ], 'all_day' => [ 'type' => 'boolean', 'description' => __( 'Whether this is an all-day event.', 'agenticdaisy-calendar' ), ], 'description' => [ 'type' => 'string', 'description' => __( 'Event description (plain text or HTML).', 'agenticdaisy-calendar' ), ], 'location' => [ 'type' => 'string', 'description' => __( 'Event location.', 'agenticdaisy-calendar' ), ], 'access' => [ 'type' => 'string', 'description' => __( 'Access level: P (Public), C (Confidential), or R (Private). Default: P.', 'agenticdaisy-calendar' ), 'enum' => ['P', 'C', 'R'], ], 'rrule' => [ 'type' => 'string', 'description' => __( 'RFC 5545 recurrence rule (e.g. FREQ=WEEKLY;BYDAY=MO,WE,FR).', 'agenticdaisy-calendar' ), ], 'category_id' => [ 'type' => 'integer', 'description' => __( 'Category ID to assign.', 'agenticdaisy-calendar' ), ], 'uid' => [ 'type' => 'string', 'description' => __( 'Optional idempotency key, stored as the event\'s iCalendar UID. Send the same value when retrying and the original event is returned instead of a duplicate; the response says created: false. Omit it and one is generated.', 'agenticdaisy-calendar' ), ], 'venue_id' => [ 'type' => 'integer', 'description' => __( 'Attach a venue by ID, from webcalendar_list_venues. Send 0 to detach it; omit to leave it unchanged. A venue is a reusable place with an address, separate from the event\'s free-text location.', 'agenticdaisy-calendar' ), ], 'organizer_id' => [ 'type' => 'integer', 'description' => __( 'Attach an organizer by ID, from webcalendar_list_organizers. Send 0 to detach it; omit to leave it unchanged. An organizer is a contact shown on the event page, not a participant.', 'agenticdaisy-calendar' ), ], 'participants' => [ 'type' => 'array', 'items' => [ 'type' => 'string', ], 'description' => __( 'Logins of WordPress users or shared/remote calendars to add as participants, in addition to the owner.', 'agenticdaisy-calendar' ), ], ], 'required' => ['title', 'start'], ], 'execute_callback' => [$this->abilities, 'execute_create_event'], 'permission_callback' => fn(): bool => current_user_can( 'webcal_can_edit' ), 'mutating' => true, ], 'webcalendar_update_event' => [ 'description' => __( 'Update an existing event. Only the fields you pass change; everything else is kept, including details this tool does not expose such as venue and organizer. A repeating event is updated as a whole series; a single occurrence cannot be edited on its own. Users without permission to edit others\' events can update only events they created.', '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 in ISO 8601 format (e.g. 2026-03-15T14:00:00). Times without a timezone offset are interpreted as the site\'s configured timezone. A date-only value (e.g. 2026-03-15) creates an all-day event.', '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 (Public), C (Confidential), or R (Private).', 'agenticdaisy-calendar' ), 'enum' => ['P', 'C', 'R'], ], 'rrule' => [ 'type' => 'string', 'description' => __( 'New RFC 5545 recurrence rule (e.g. FREQ=WEEKLY;BYDAY=MO). An empty string makes the event non-repeating.', 'agenticdaisy-calendar' ), ], 'category_id' => [ 'type' => 'integer', 'description' => __( 'Category ID to assign (0 to remove).', 'agenticdaisy-calendar' ), ], 'venue_id' => [ 'type' => 'integer', 'description' => __( 'Attach a venue by ID, from webcalendar_list_venues. Send 0 to detach it; omit to leave it unchanged. A venue is a reusable place with an address, separate from the event\'s free-text location.', 'agenticdaisy-calendar' ), ], 'organizer_id' => [ 'type' => 'integer', 'description' => __( 'Attach an organizer by ID, from webcalendar_list_organizers. Send 0 to detach it; omit to leave it unchanged. An organizer is a contact shown on the event page, not a participant.', 'agenticdaisy-calendar' ), ], 'participants' => [ 'type' => 'array', 'items' => [ 'type' => 'string', ], 'description' => __( 'Logins of WordPress users or shared/remote calendars to set as participants. Replaces the existing list; the owner is always kept.', 'agenticdaisy-calendar' ), ], ], 'required' => ['event_id'], ], 'execute_callback' => [$this->abilities, 'execute_update_event'], 'permission_callback' => fn(): bool => current_user_can( 'webcal_can_edit' ), 'mutating' => true, ], 'webcalendar_delete_event' => [ 'description' => __( 'Permanently delete an event by ID; this cannot be undone. Deleting a repeating event deletes every occurrence, since occurrences share the series ID. Users without permission to delete others\' events can delete only events they created.', '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(): bool => current_user_can( 'webcal_can_delete' ), 'mutating' => true, ], 'webcalendar_list_categories' => [ 'description' => __( 'Retrieve the event categories visible to the connected user (ID, name and color). The ID is the value the category_id fields of the other calendar tools take.', 'agenticdaisy-calendar' ), 'input_schema' => [ 'type' => 'object', 'properties' => new \stdClass(), ], 'execute_callback' => [$this->abilities, 'execute_list_categories'], 'permission_callback' => fn(): bool => current_user_can( 'webcal_can_view' ), ], 'webcalendar_get_calendar_info' => [ 'description' => __( 'Get basic site information: plugin version, the number of events the connected user may see, the number of categories visible to the connected user, the time format setting (12 or 24 hour), and the site name.', 'agenticdaisy-calendar' ), 'input_schema' => [ 'type' => 'object', 'properties' => new \stdClass(), ], 'execute_callback' => [$this->abilities, 'execute_get_calendar_info'], 'permission_callback' => fn(): bool => current_user_can( 'webcal_can_view' ), ], 'webcalendar_list_venues' => [ 'description' => __( 'List the venues on this site: reusable places with an address, which events are attached to by ID rather than by repeating the address. The ID is the value the venue_id fields take. A venue is not the same as an event\'s free-text location — an event can have both.', 'agenticdaisy-calendar' ), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'search' => [ 'type' => 'string', 'description' => __( 'Optional term matched against the venue name; omit to list all.', 'agenticdaisy-calendar' ), ], ], ], 'execute_callback' => [$this->abilities, 'execute_list_venues'], 'permission_callback' => fn(): bool => current_user_can( 'webcal_can_view' ), ], 'webcalendar_list_organizers' => [ 'description' => __( 'List the organizers on this site: reusable contacts shown on an event page as who is running it. The ID is the value the organizer_id fields take. An organizer is not a participant — organizers are contact details displayed publicly, participants are calendar users invited to attend.', 'agenticdaisy-calendar' ), 'input_schema' => [ 'type' => 'object', 'properties' => [ 'search' => [ 'type' => 'string', 'description' => __( 'Optional term matched against the organizer name; omit to list all.', 'agenticdaisy-calendar' ), ], ], ], 'execute_callback' => [$this->abilities, 'execute_list_organizers'], 'permission_callback' => fn(): bool => current_user_can( 'webcal_can_view' ), ], 'webcalendar_describe_schema' => [ 'description' => __( 'Describe this calendar\'s conventions and settings: the site timezone and date formats that bare times are read in, the fields an event has and which are required, what the single-letter access and type codes mean, the paging and rate limits, and which optional features are available on this site. Call it once before creating or interpreting events rather than assuming defaults.', 'agenticdaisy-calendar' ), 'input_schema' => [ 'type' => 'object', 'properties' => new \stdClass(), ], 'execute_callback' => [$this->abilities, 'execute_describe_schema'], 'permission_callback' => fn(): bool => current_user_can( 'webcal_can_view' ), ], ]; return $map; } /** * 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, ], ]; } }