claimed_default = true; return $config; } /** * Raise the transport capability for our route only. * * Scoped by request route so other MCP servers on the site keep the adapter's * own default. Returning the unfiltered value for anything else matters: this * filter is global, and tightening a sibling plugin's server would break it. * * @param mixed $capability Capability the transport will check. * @param mixed $context Request context (HttpRequestContext). * @return mixed */ public function route_capability( $capability, $context = null ) { $route = ''; if ( is_object( $context ) && isset( $context->request ) && $context->request instanceof \WP_REST_Request ) { $route = untrailingslashit( (string) $context->request->get_route() ); } // Exact route or a sub-path of it, never a substring match — that would // also catch a sibling server whose route merely contains ours (e.g. // `/mcp/zip-ai-foo`) and break it by raising its capability. $base = '/' . self::NAMESPACE . '/' . self::ROUTE; if ( $route !== $base && ! str_starts_with( $route, $base . '/' ) ) { return $capability; } return self::capability(); } /** * The capability this endpoint demands. Filterable so a site can deliberately * widen it (e.g. to let editors import pages with their own application * password) — every ability still enforces its own capability underneath, so * lowering this cannot grant anything the caller lacks. * * @return string */ public static function capability() { $capability = apply_filters( 'zip_ai_external_mcp_capability', self::CAPABILITY ); return is_string( $capability ) && '' !== $capability ? $capability : self::CAPABILITY; } /** * Surface a failure that the adapter's dispatcher hides. * * `mcp-adapter/execute-ability` wraps the target result as * `{success: true, data: }`. This means "the dispatch worked". The * inner value can itself be `{success: false, error: …}`. In that case the * outer flag still reads true and `isError` is never set. An agent that * checks the top level then marches past a real failure. For example, a * plugin install that did not happen, or an import that was rejected. * * This method returns the inner envelope. The adapter then recognises the * failure and marks the result as an error. The other sibling fields ride * along in the message. The adapter's error path keeps only a string. The * `suggestion` and `violations` fields let the agent self-correct. It does * not need another round-trip. * * @param mixed $result The tool call result. * @param array $args Call arguments (unused). * @param string $tool_name MCP tool name (slashes become hyphens). * @return mixed */ public function unmask_failed_dispatch( $result, $args, $tool_name ) { unset( $args ); if ( ! is_array( $result ) ) { return $result; } // The dispatcher reports "the dispatch worked" even when the ability it // ran failed. Unwrap one level so the real envelope is judged below. if ( 'mcp-adapter-execute-ability' === $tool_name && true === ( $result['success'] ?? null ) ) { $inner = $result['data'] ?? null; if ( is_array( $inner ) && false === ( $inner['success'] ?? null ) ) { $result = $inner; } } if ( false !== ( $result['success'] ?? null ) ) { return $result; } $error = isset( $result['error'] ) && is_string( $result['error'] ) ? trim( $result['error'] ) : ''; if ( '' === $error ) { return $result; } // Fold the remaining fields into the message. The adapter's error path // keeps ONLY this string — `suggestion` and `data` (repair hints such as // `violations`, and the import consent token) are otherwise dropped // before the client ever sees them. $suggestion = isset( $result['suggestion'] ) && is_string( $result['suggestion'] ) ? trim( $result['suggestion'] ) : ''; if ( '' !== $suggestion && ! str_contains( $error, $suggestion ) ) { $error .= ' ' . $suggestion; } if ( isset( $result['data'] ) && is_array( $result['data'] ) && ! empty( $result['data'] ) ) { $encoded = wp_json_encode( $result['data'] ); if ( false !== $encoded ) { $error .= ' ' . $encoded; } } return array( 'success' => false, 'error' => $error, ); } /** * Whether the external endpoint is switched on for this site. Defaults to * OFF; the endpoint is opt-in, so a site with no option row stays closed * until an admin turns it on in the Connection screen. * * Stored as the STRING '1' / '0', never a boolean. `get_option()` cannot tell * a stored `false` from a missing row — both arrive as `false` — so a string * keeps an explicit ON distinguishable from a never-touched site. * * @return bool */ public static function is_enabled() { $stored = get_option( self::OPTION_KEY, self::DISABLED ); // A legacy boolean/empty value (written before this key was normalised to // a string) is treated as an explicit OFF, matching what the admin chose. $enabled = is_string( $stored ) ? self::ENABLED === $stored : (bool) $stored; /** * Filter whether the external MCP endpoint is available. Return false to * force it off site-wide regardless of the stored setting. * * @param bool $enabled Current state. */ return (bool) apply_filters( 'zip_ai_external_mcp_enabled', $enabled ); } /** * Persist the on/off state. Kept here so the storage format lives with the * reader that depends on it. * * @param bool $enabled Desired state. * @return void */ public static function set_enabled( bool $enabled ) { update_option( self::OPTION_KEY, $enabled ? self::ENABLED : self::DISABLED ); } /** * The endpoint URL clients should be given. * * @return string */ public static function server_url() { return rest_url( self::NAMESPACE . '/' . self::ROUTE ); } /** * Register the curated server with the adapter. * * @param \WP\MCP\Core\McpAdapter $adapter The adapter that fired this hook. * @return void */ public function register_server( $adapter ) { if ( ! self::is_enabled() ) { return; } // The default server was successfully rebranded as ours — nothing to add. if ( $this->claimed_default ) { return; } // Someone else owns the default server. Stand up our own so the endpoint // exists either way; it costs one extra route and keeps the URL stable. if ( null !== $adapter->get_server( self::SERVER_ID ) ) { return; } /** * Filter the abilities exposed to external AI clients. * * Return a NARROWER list to lock a site down. Widening past Era's own * abilities is not possible — the namespace bound is re-applied after * this filter. * * @param string[] $tools Ability ids. */ // Cast + filter rather than trust: a third-party filter is under no // obligation to honour the documented shape, and a junk entry here would // surface as an adapter-side registration failure. $tools = array_values( array_filter( (array) apply_filters( 'zip_ai_external_mcp_tools', External_Tool_Policy::ADVERTISED ), 'is_string' ) ); if ( empty( $tools ) ) { return; } $adapter->create_server( self::SERVER_ID, self::NAMESPACE, self::ROUTE, // Same identity the rebrand path uses — a fallback server must not // present itself differently to a client. 'ZIP AI', 'Import AI-authored HTML into this WordPress site as native blocks, and run this site\'s abilities.', ZIPAI_MCP_VERSION, array( \WP\MCP\Transport\HttpTransport::class ), null, null, $tools, array(), array(), array( $this, 'check_transport_permission' ) ); } /** * Transport-level gate for the fallback server, where we DO pass a callback * (so the `route_capability` filter is never consulted). Same bar as the * rebrand path; each ability still enforces its own capability on top. * * @return bool */ public function check_transport_permission() { return is_user_logged_in() && current_user_can( self::capability() ); } }