$arguments Validated arguments. * @param Request_Context $context Context. */ abstract protected function run( array $arguments, Request_Context $context ): Tool_Result; /** * Default annotations. Override read_only()/destructive()/idempotent() to tune. * * @return array */ public function get_annotations(): array { return array( 'title' => $this->get_title(), 'readOnlyHint' => $this->is_read_only(), 'destructiveHint' => $this->is_destructive(), 'idempotentHint' => $this->is_idempotent(), 'openWorldHint' => false, ); } /** * Optional MCP `_meta` for the tool descriptor — e.g. MCP Apps' `ui` * block, whose `resourceUri` binds the tool to a UI resource the host * renders. Empty by default; the registry omits it when empty. * * Only a tool that IS a panel should bind one. A host mounts a separate * panel for every call of a bound tool, so binding a work tool gives the * person a new card per call; progress goes through wp_task_start instead. * * @return array */ public function get_meta(): array { return array(); } /** * The capability this tool requires, readable from outside. * * The approval gate stores it with a held call so the approval screen can * insist the person signing it off actually holds it — the check cannot be * deferred to the tool itself, because at that point nothing has run yet. */ public function get_required_capability(): string { return $this->required_capability(); } /** * The capability a person needs to approve this call when it is held. * * Defaults to the tool's own capability. A general-purpose tool with a low * floor (REST, WP-CLI, abilities) raises it to what the held call actually * does, so a user who could not run the delete themselves cannot approve it. * * @param array $arguments Validated arguments. */ public function get_approval_capability( array $arguments ): string { return $this->required_capability(); } /** * Whether the tool only reads data. Override in mutating tools. */ protected function is_read_only(): bool { return false; } /** * Whether the tool may irreversibly destroy data (e.g. permanent delete). */ protected function is_destructive(): bool { return false; } /** * Whether repeated identical calls have the same effect as one call. */ protected function is_idempotent(): bool { return false; } /** * Whether this particular call may destroy data. * * This, not the destructiveHint annotation, is what the "Destructive * actions" switch and the approval gate act on. It defaults to the tool-level * declaration; a tool whose risk depends on its arguments overrides it — a * search-replace dry run changes nothing, a REST DELETE removes data through * a tool that is otherwise harmless. * * @param array $arguments Validated arguments. */ public function is_destructive_call( array $arguments ): bool { return $this->is_destructive(); } /** * What the refusal says when "Destructive actions" is off and this call * is one the setting covers, or '' for the general wording. A call that * deletes nothing (giving an account admin-level access) says what it * does instead. * * @param array $arguments Validated arguments. */ public function destructive_refusal( array $arguments ): string { return ''; } /** * Cheap checks for a call that cannot succeed, run after validation and * before the call is held for approval or executed — so nobody is asked to * approve a refund larger than the order, or a delete of something that is * not there. * * Advisory only: run() still does its own checks. An approved call is * preflighted again before it runs, since the site may have changed. * * @param array $arguments Validated arguments. * @param Request_Context $context Context. * @return true|\WP_Error */ public function preflight( array $arguments, Request_Context $context ) { return true; } /** * A fingerprint of what a held call acts on, taken when it is held and * taken again when it is approved, or '' when nothing needs comparing. * * The approval page describes the page, element or setting as it was * when the call was held. If it changed before anyone approved, running * the call would do something other than what they read, so a mismatch * stops the approved call (see Approval_Runner). * * @param array $arguments Validated arguments. */ public function approval_state( array $arguments ): string { return ''; } /** * Full call-time check: the account may use the tool, and the site's * settings currently allow it. * * @param Request_Context $context Context. * @return true|\WP_Error */ public function check_permission( Request_Context $context ) { $access = $this->check_access( $context ); if ( is_wp_error( $access ) ) { return $access; } return $this->check_settings(); } /** * Settings switches this tool depends on (e.g. "Allow file editing"). * * Kept apart from check_access() on purpose: tools/list filters on access * alone, so a tool behind a switch stays listed while the switch is off. * Clients fetch the tool list once at connect and never hear about later * changes, so hiding it would mean flipping the switch has no effect until * the person reconnects. Enforced at call time instead, where the error * tells the assistant which setting to turn on. * * @return true|\WP_Error */ protected function check_settings() { return true; } /** * Whether the account can use this tool at all, regardless of settings * switches. This decides tools/list visibility. Override for per-object * checks. * * @param Request_Context $context Context. * @return true|\WP_Error */ public function check_access( Request_Context $context ) { $capability = $this->required_capability(); if ( '' !== $capability && ! $context->user_can( $capability ) ) { return new \WP_Error( 'gripmcp_forbidden', sprintf( /* translators: 1: tool title, 2: capability. */ __( 'You do not have permission to use "%1$s" (requires the "%2$s" capability).', 'gripmcp' ), $this->get_title(), $capability ) ); } return true; } /** * Final execute wraps run() so subclasses need not repeat boilerplate. * * Anything printed while a tool works (a plugin's notice, a widget that * Elementor renders during a save, the buffers a failed render leaves * open) would go out ahead of the JSON response and corrupt it: the * client would read an edit that was saved as one that failed. So it is * caught here and dropped. * * A tool that can change the site also tells wp_page_html, so a page * being read in parts is not shown from a copy made before the change. * * @param array $arguments Validated arguments. * @param Request_Context $context Context. */ final public function execute( array $arguments, Request_Context $context ): Tool_Result { $level = ob_get_level(); ob_start(); try { return $this->run( $arguments, $context ); } finally { self::drop_output( $level ); if ( ! $this->is_read_only() ) { Page_Html_Tool::forget_copies( $context->user_id() ); } } } /** * Close every output buffer opened above a level, dropping what is in * them. Code that throws part-way through its own buffering leaves its * buffers open, so closing just the one opened here is not enough. * * @param int $level Buffer level to return to. */ protected static function drop_output( int $level ): void { while ( ob_get_level() > $level ) { // A buffer that may not be removed would stay at this level forever. if ( ! ob_end_clean() ) { break; } } } /** * Convenience: success text result with optional structured payload. * * @param string $text Text. * @param array|null $structured Structured payload. */ protected function ok( string $text, ?array $structured = null ): Tool_Result { return Tool_Result::text( $text, $structured ); } /** * Convenience: structured result with a human summary. * * @param array $data Data. * @param string $summary Summary. */ protected function data( array $data, string $summary = '' ): Tool_Result { return Tool_Result::data( $data, $summary ); } /** * Convenience: error result. * * @param string $message Message. */ protected function fail( string $message ): Tool_Result { return Tool_Result::error( $message ); } }