settings = $settings; $this->registry = $registry; $this->envelope = new Envelope( $settings, $registry ); $this->signer = new Signer( $settings ); } /** * Hook the (narrow) rewrite rule and the front-controller route. */ public function register() { add_action( 'init', array( __CLASS__, 'add_rules' ) ); add_action( 'template_redirect', array( $this, 'route' ), 0 ); } /** * The well-known names this plugin is authoritative for. Providers serving a * doc on a server that won't front-controller /.well-known to PHP can add * their name here (and flush rewrites) to guarantee it routes. * * @return string[] */ public static function routed_names() { // ONLY names this plugin actually serves — derived from flat_docs(), so the // rewrite alternation, the CORS scope and route()'s dispatch can never fall // out of step with each other. Routing a name we don't serve is harmful: with // our rewrite tag set but no body produced, WordPress's canonical redirect // resolves it to the homepage (a 200, not a 404). security.txt etc. are // intentionally absent — a provider that serves one adds its name via the // `agentimus_well_known_routed` filter. $names = array_keys( self::flat_docs() ); /** * Filter the /.well-known names routed to WordPress by an explicit rule. * * @param string[] $names Doc names (no leading slash). */ return array_values( array_unique( (array) apply_filters( 'agentimus_well_known_routed', $names ) ) ); } /** * The flat /.well-known docs this plugin generates, each with its content type * and whether it is GATED — served only when its body is non-empty, so a feature * that is turned off yields no body and route() falls through to a clean 404. * * The single source for the routed names, the CORS scope, the rewrite alternation * and route()'s dispatch metadata; the body for each name is produced by * {@see flat_body()}. Order matters — it fixes the rewrite-rule alternation. * * @return array */ public static function flat_docs() { return array( 'discovery.json' => array( 'ct' => 'application/json', 'gated' => false ), 'agent-card.json' => array( 'ct' => 'application/json', 'gated' => false ), 'agent.json' => array( 'ct' => 'application/json', 'gated' => false ), 'mcp.json' => array( 'ct' => 'application/json', 'gated' => false ), 'openapi.json' => array( 'ct' => 'application/json', 'gated' => false ), 'api-catalog' => array( 'ct' => 'application/linkset+json; profile="https://www.rfc-editor.org/info/rfc9727"', 'gated' => false ), 'agent-skills' => array( 'ct' => 'text/markdown', 'gated' => false ), 'oauth-protected-resource' => array( 'ct' => 'application/json', 'gated' => true ), 'tdmrep.json' => array( 'ct' => 'application/json', 'gated' => true ), Signer::DIRECTORY => array( 'ct' => 'application/json', 'gated' => true ), ); } /** * Nested /.well-known docs this plugin serves — names that contain a '/' and so * can't go through the flat router. An EXACT allow-list (never a wildcard), each * backed by a dedicated rewrite rule, so we never capture a nested namespace we * don't own. Currently the MCP Server Card and the Agent Skills index. * * @return string[] */ public static function nested_routes() { // The two oauth paths are RFC-mandated derivations of the built-in OAuth // server's path-based issuer (…/agentimus/mcp) — path-suffixed exactly so // they can never collide with another plugin's OAuth documents. $names = array( 'mcp/server-card.json', 'agent-skills/index.json', // The site's Agent Skill (agentskills.io). The directory segment is the // site-derived skill name — the spec requires the two to match. 'agent-skills/' . SkillMd::name() . '/SKILL.md', 'oauth-authorization-server/agentimus/mcp', 'oauth-protected-resource/agentimus/mcp', ); // The SAME protected-resource document, at the location RFC 9728 §3.1 // derives from the MCP endpoint's own URL (…/oauth-protected-resource/ // wp-json/agentimus/v1/mcp). The 401's WWW-Authenticate hint points at // the issuer-path form above and Claude follows it, but ChatGPT computes // this form itself and reads a 404 as "does not implement OAuth". $derived = self::mcp_resource_prm_route(); if ( '' !== $derived ) { $names[] = $derived; } /** * Filter the nested /.well-known names routed to WordPress. * * @param string[] $names Nested doc paths (no leading slash). */ return array_values( array_unique( (array) apply_filters( 'agentimus_well_known_nested', $names ) ) ); } /** * The RFC 9728 §3.1 path-insertion form for the MCP endpoint itself: a client * takes the resource URL ({site}/wp-json/agentimus/v1/mcp), inserts * /.well-known/oauth-protected-resource after the host, and expects the * metadata there. Serving it only at the issuer-path form is not enough — * a client that never reads the WWW-Authenticate resource_metadata hint * treats the 404 at this address as "no OAuth on this server". * * @return string Nested route name, or '' when the REST API has no path * form (plain permalinks address it via ?rest_route=). */ public static function mcp_resource_prm_route() { $prefix = trim( (string) rest_get_url_prefix(), '/' ); if ( '' === $prefix ) { return ''; } return 'oauth-protected-resource/' . $prefix . '/agentimus/v1/mcp'; } /** * Route ONLY our own names to WordPress, so requests reach template_redirect * even on servers that 404 the path at disk level. A narrow alternation (not * a catch-all) means we never capture — and therefore never have to 404 — * paths that belong to someone else. Flushed on activation. */ public static function add_rules() { $alt = implode( '|', array_map( static function ( $name ) { return preg_quote( $name ); }, self::routed_names() ) ); if ( '' === $alt ) { return; } add_rewrite_rule( '^\.well-known/(' . $alt . ')$', 'index.php?wpd_well_known=$matches[1]', 'top' ); add_rewrite_tag( '%wpd_well_known%', '([^/]+)' ); // Nested docs (mcp/server-card.json, …) get a dedicated EXACT-match rule, so // the capture can contain a '/' without opening a wildcard nesting surface. $nested = implode( '|', array_map( static function ( $name ) { return preg_quote( $name ); }, self::nested_routes() ) ); if ( '' !== $nested ) { add_rewrite_rule( '^\.well-known/(' . $nested . ')$', 'index.php?wpd_well_known_nested=$matches[1]', 'top' ); add_rewrite_tag( '%wpd_well_known_nested%', '(.+)' ); } // Per-server MCP cards: a CONTROLLED wildcard for the {id} segment only, bounded // by the exact mcp/.../server-card.json shape — never a general nesting surface. add_rewrite_rule( '^\.well-known/mcp/([^/]+)/server-card\.json$', 'index.php?wpd_well_known_nested=mcp/$matches[1]/server-card.json', 'top' ); } /** * Resolve a /.well-known/* request. Runs before the template at priority 0. */ public function route() { if ( is_admin() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { return; } $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET'; $uri = \Agentimus\Request::uri(); $path = '/' . ltrim( (string) wp_parse_url( $uri, PHP_URL_PATH ), '/' ); if ( 0 !== strpos( $path, self::PREFIX ) ) { return; } $name = trim( substr( $path, strlen( self::PREFIX ) ), '/' ); if ( '' === $name || false !== strpos( $name, '..' ) ) { return; // Never serve a traversal path. } // CORS preflight: a cross-origin agent fetching a doc with non-simple headers // sends an OPTIONS first. Answer it — but ONLY for names we actually serve, so // we never shadow another component's /.well-known/* OPTIONS handling. (The // real GET already carries Access-Control-Allow-Origin: * via send()/stream().) if ( 'OPTIONS' === $method ) { if ( $this->serves( $name ) ) { $this->preflight(); } return; } if ( 'GET' !== $method && 'HEAD' !== $method ) { return; } // Nested, whitelisted docs (e.g. mcp/server-card.json) contain a '/', which // the flat handling below rejects — dispatch them first against an EXACT // allow-list. A real on-disk file still wins; an empty generator (the doc is // gated off) falls through to a clean 404. if ( false !== strpos( $name, '/' ) ) { if ( in_array( $name, self::nested_routes(), true ) ) { $nested_file = \Agentimus\Paths::site_root() . '.well-known/' . $name; if ( is_file( $nested_file ) && 0 === strpos( wp_normalize_path( (string) realpath( $nested_file ) ), wp_normalize_path( \Agentimus\Paths::site_root() . '.well-known/' ) ) ) { $this->stream( $nested_file ); } $this->registry->collect(); $this->route_nested( $name ); } elseif ( preg_match( '#^mcp/([^/]+)/server-card\.json$#', $name, $m ) ) { // A per-server MCP card — the {id} is the captured segment. $this->registry->collect(); $body = $this->envelope->mcp_server_card_json_for( rawurldecode( $m[1] ) ); if ( '' !== $body ) { $this->send( $body, 'application/json', $name ); } } $this->maybe_clean_404(); return; } // 1. Real file on disk wins. If the server didn't serve it, stream it. $file = \Agentimus\Paths::site_root() . '.well-known/' . $name; if ( is_file( $file ) && 0 === strpos( wp_normalize_path( realpath( $file ) ), wp_normalize_path( \Agentimus\Paths::site_root() . '.well-known/' ) ) ) { $this->stream( $file ); } $this->registry->collect(); // 2. Documents this plugin generates — metadata (content type, gating) from // flat_docs(), the body from flat_body(). A GATED doc whose feature is off // yields an empty body and is NOT sent, so route() falls through to a clean // 404; an ungated doc sends whatever it produced. A null body means the name // has no producer (a drift from flat_docs) — treated as "nothing produced". $docs = self::flat_docs(); if ( isset( $docs[ $name ] ) ) { $body = $this->flat_body( $name ); if ( null !== $body && ( ! $docs[ $name ]['gated'] || '' !== $body ) ) { $this->send( $body, $docs[ $name ]['ct'], $name ); } } // 3. Provider-registered documents. $providers = $this->registry->well_known(); if ( isset( $providers[ $name ] ) ) { $this->serve_provider( $providers[ $name ] ); } // 4. We produced nothing — force a clean 404 if WE routed this request. $this->maybe_clean_404(); } /** * Produce the body for one flat /.well-known doc, or null when the name has no * producer here (a drift from {@see flat_docs()}). A gated doc whose feature is * off returns '' — the caller turns that into a clean 404. * * @param string $name Flat doc name (already matched against flat_docs()). * @return string|null */ private function flat_body( $name ) { switch ( $name ) { case 'discovery.json': return $this->envelope->discovery_json(); case 'agent-card.json': case 'agent.json': // Alias for agents that look here first. return $this->envelope->agent_card_json(); case 'mcp.json': return $this->envelope->mcp_json(); case 'openapi.json': // OpenAPI 3.1 description of the existing public REST read API. return ( new OpenApi( $this->settings ) )->json(); case 'api-catalog': // RFC 9727 API catalog, as an RFC 9264 link set. The profile parameter // on the content type (see flat_docs()) tells a client this linkset IS // an API catalog, and validators check for it. return $this->envelope->api_catalog_json(); case 'agent-skills': // The bare directory path — a markdown listing of the packaged skills, // so a probe of the namespace itself learns where SKILL.md lives. return $this->envelope->skill_directory_md(); case 'oauth-protected-resource': // RFC 9728 — non-empty only when the owner declared an auth server. return $this->envelope->oauth_protected_resource_json(); case Signer::DIRECTORY: // The Web Bot Auth key directory — non-empty only when signing is on. return $this->signer->directory(); case 'tdmrep.json': // TDM Reservation Protocol opt-out file — non-empty only when gated on. return ( new \Agentimus\Tdmrep( $this->settings ) )->json(); } return null; } /** * Dispatch a whitelisted nested /.well-known doc. Sends (and exits) on a * non-empty body; an empty body means the doc is gated off (no MCP server, no * abilities), so we return and let route() emit a clean 404 — never a stub. * * @param string $name Nested doc path (already allow-list checked). */ private function route_nested( $name ) { // The path-derived alias of the MCP protected-resource document (see // mcp_resource_prm_route()) — same body as the issuer-path form below. if ( self::mcp_resource_prm_route() === $name ) { $body = $this->envelope->oauth_mcp_protected_resource_json(); if ( '' !== $body ) { $this->send( $body, 'application/json', 'oauth-protected-resource-mcp' ); } return; } switch ( $name ) { case 'mcp/server-card.json': $body = $this->envelope->mcp_server_card_json(); if ( '' !== $body ) { $this->send( $body, 'application/json', 'mcp/server-card.json' ); } break; case 'agent-skills/index.json': $body = $this->envelope->agent_skills_index_json(); if ( '' !== $body ) { $this->send( $body, 'application/json', 'agent-skills/index.json' ); } break; case 'agent-skills/' . SkillMd::name() . '/SKILL.md': $this->send( $this->envelope->skill_md(), 'text/markdown', 'agent-skills/SKILL.md' ); break; case 'oauth-authorization-server/agentimus/mcp': $body = $this->envelope->oauth_authorization_server_json(); if ( '' !== $body ) { $this->send( $body, 'application/json', 'oauth-authorization-server' ); } break; case 'oauth-protected-resource/agentimus/mcp': $body = $this->envelope->oauth_mcp_protected_resource_json(); if ( '' !== $body ) { $this->send( $body, 'application/json', 'oauth-protected-resource-mcp' ); } break; } } /** * If WE routed this request (either rewrite tag is set) and produced nothing, * WordPress's canonical redirect would resolve it to the homepage — force a * clean 404 instead. A name we never routed is left entirely untouched. */ private function maybe_clean_404() { if ( '' === (string) get_query_var( 'wpd_well_known' ) && '' === (string) get_query_var( 'wpd_well_known_nested' ) ) { return; } global $wp_query; if ( $wp_query instanceof \WP_Query ) { $wp_query->set_404(); } status_header( 404 ); nocache_headers(); } /* ---------------------------------------------------------------------- * * Emitters * ---------------------------------------------------------------------- */ /** * Serve a provider-registered well-known doc (redirect | file | callback). * * @param array $def Provider definition. */ private function serve_provider( $def ) { if ( ! empty( $def['redirect'] ) ) { $target = '/' === $def['redirect'][0] ? home_url( $def['redirect'] ) : $def['redirect']; wp_safe_redirect( $target, 302 ); exit; } if ( ! empty( $def['file'] ) && is_file( $def['file'] ) ) { $this->stream( $def['file'], $def['content_type'] ); } if ( ! empty( $def['callback'] ) && is_callable( $def['callback'] ) ) { $body = (string) call_user_func( $def['callback'] ); $this->send( $body, $def['content_type'] ); } } /** * Whether `$name` is a /.well-known doc this plugin actually serves — the flat * routed names, the nested allow-list, or a per-server MCP card. Used to scope * the CORS preflight so we only answer OPTIONS for paths we own. * * @param string $name Doc name (no leading slash). * @return bool */ private function serves( $name ) { if ( in_array( $name, self::routed_names(), true ) || in_array( $name, self::nested_routes(), true ) ) { return true; } return (bool) preg_match( '#^mcp/([^/]+)/server-card\.json$#', $name ); } /** * Answer a CORS preflight for a doc we serve: the access-control headers a * strict cross-origin agent expects, a 204, and no body. */ private function preflight() { if ( ! headers_sent() ) { status_header( 204 ); header_remove( 'Expires' ); // Drop WP's 404-path no-cache headers (see send()). header_remove( 'Pragma' ); header( 'Access-Control-Allow-Origin: *' ); header( 'Access-Control-Allow-Methods: GET, HEAD, OPTIONS' ); header( 'Access-Control-Allow-Headers: *' ); header( 'Access-Control-Max-Age: 86400' ); header( 'Content-Length: 0' ); } exit; } /** * Emit a generated body with cache-friendly headers, then stop. * * @param string $body Response body. * @param string $content_type MIME type. * @param string $label Activity-log endpoint label (empty = no log). */ private function send( $body, $content_type, $label = '' ) { // Optional hard enforcement (opt-in): deny denylisted/spoofed agents before // emitting a generated doc. On-disk files served by stream() are left alone // (so ACME challenges etc. never break). The label rides along so a refusal // of a proven impostor lands in the ledger like the serve just below would — // an unlabelled doc stays unlogged either way. \Agentimus\Guard::maybe_block( $label ); if ( '' !== $label ) { \Agentimus\Activity\Recorder::record( $label ); } if ( ! headers_sent() ) { self::emit_doc_headers( $content_type ); // Web Bot Auth: sign the low-volume discovery JSON docs when enabled. if ( in_array( $label, $this->signed_surfaces(), true ) && $this->signer->enabled() ) { foreach ( $this->signer->sign( $body, $this->current_url() ) as $sig_header => $sig_value ) { header( $sig_header . ': ' . $sig_value, false ); } } } if ( ! $this->is_head() ) { echo $body; // phpcs:ignore WordPress.Security.EscapeOutput -- JSON/plain payload. } exit; } /** * The discovery docs that receive RFC 9421 response signatures when signing is * on. Filterable — but broadening to cached HTML / llms.txt would mean an * edge-cached body carrying a frozen signature, so keep it to the JSON docs. * * @return string[] */ private function signed_surfaces() { return (array) apply_filters( 'agentimus_signed_surfaces', array( 'discovery.json', 'agent-card.json', 'agent.json', 'mcp.json' ) ); } /** * The absolute URL of the current request, for the signature's @target-uri. * * @return string */ private function current_url() { $uri = \Agentimus\Request::uri(); $path = '/' . ltrim( (string) wp_parse_url( $uri, PHP_URL_PATH ), '/' ); // Take the canonical scheme+host from home_url() but keep the LITERAL request // path. home_url( $path ) would re-prepend the site's subpath on a "WordPress // in a subdirectory" install (e.g. /blog/blog/.well-known/…), so the signed // @target-uri wouldn't match the URL the agent actually fetched, and every // signature would fail to verify there. $home = (array) wp_parse_url( home_url( '/' ) ); $scheme = ! empty( $home['scheme'] ) ? $home['scheme'] : ( is_ssl() ? 'https' : 'http' ); $host = isset( $home['host'] ) ? $home['host'] : ''; if ( ! empty( $home['port'] ) ) { $host .= ':' . $home['port']; } return '' !== $host ? $scheme . '://' . $host . $path : home_url( $path ); } /** * Stream a static file from disk with a sane MIME type, then stop. * * @param string $file Absolute path. * @param string $content_type Optional override. */ private function stream( $file, $content_type = '' ) { if ( '' === $content_type ) { $content_type = 'json' === strtolower( (string) pathinfo( $file, PATHINFO_EXTENSION ) ) ? 'application/json' : 'text/plain'; } if ( ! headers_sent() ) { self::emit_doc_headers( $content_type ); } if ( ! $this->is_head() ) { readfile( $file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_readfile -- streaming a discovery doc. } exit; } /** * The header block shared by send() and stream(): a clean 200, WordPress's * 404-path no-cache headers cleared (a name reached via the 404-intercept * inherits them, e.g. security.txt — clearing them lets our Cache-Control * govern and keeps the doc edge-cacheable), the content type, nosniff, and the * public cross-origin allowance. * * @param string $content_type MIME type. * @return void */ private static function emit_doc_headers( $content_type ) { status_header( 200 ); header_remove( 'Expires' ); header_remove( 'Pragma' ); header( 'Content-Type: ' . $content_type . '; charset=UTF-8' ); header( 'X-Content-Type-Options: nosniff' ); header( 'Access-Control-Allow-Origin: *' ); // Discovery docs are public by design. \Agentimus\CacheHeaders::send( 3600 ); } /** * @return bool Whether this is a HEAD request. */ private function is_head() { return \Agentimus\Request::is_head(); } }