settings = $settings; $this->registry = $registry; } /** * The master discovery.json, JSON-encoded (cached). * * @return string */ public function discovery_json() { $cached = Cache::get( Cache::DISCOVERY ); if ( is_string( $cached ) && '' !== $cached ) { return $cached; } $json = wp_json_encode( $this->build(), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ); $json = is_string( $json ) ? $json : '{}'; Cache::set( Cache::DISCOVERY, $json ); return $json; } /** * The generated A2A agent-card document, JSON-encoded. * * @return string */ public function agent_card_json() { return $this->docs_builder()->agent_card_json(); } /** * The RFC 9727 API catalog at /.well-known/api-catalog, as an RFC 9264 link set * (`application/linkset+json`). Points agents at this site's API descriptions: * the discovery document (service-desc), the WordPress REST root, and every API * base already derived for the discovery envelope. The document complement to * the `rel="api-catalog"` Link header — same information, fetchable at the * standard well-known path some scanners check directly. * * @return string */ public function api_catalog_json() { return $this->docs_builder()->api_catalog_json(); } /* ---------------------------------------------------------------------- * * Build * ---------------------------------------------------------------------- */ /** * Assemble the full envelope array. * * @return array */ public function build() { $resources = $this->published_resources(); // The frozen wire-format core: exactly the eleven top-level keys the spec // (§02 / M2) defines, in order. Experimental surfaces — the MCP descriptor // and the tool list — are deliberately NOT baked into the stable contract; // they are served at /.well-known/mcp.json (linked from `well_known`) so they // can track the still-unsettled MCP discovery proposal without a wire bump. // A consumer needing extras inline can add `x-`-prefixed keys via the // `agentimus_envelope` filter; the unprefixed namespace is the spec's. $envelope = array( /** * The JSON Schema URL. Filter to point at a different (e.g. GitHub- * hosted) home until the canonical schema is published. * * @param string $url Schema URL. */ '$schema' => self::schema_url(), 'spec_version' => self::SPEC_VERSION, 'site' => $this->site(), 'identity' => $this->identity(), 'documents' => $this->documents(), 'well_known' => $this->well_known_index(), 'apis' => $this->apis( $resources ), 'agents' => $this->agents( $resources ), 'resources' => array_map( array( $this, 'wire_resource' ), $resources ), 'capabilities' => $this->capabilities( $resources ), // Cast to object so an empty trust block encodes as {}, not []. 'trust' => (object) $this->trust(), ); /** * Filter the assembled discovery envelope before encoding. * * @param array $envelope The envelope. * @param Registry $registry The collector. */ // The envelope is the discovery document: it's json_encoded for the public // endpoints and array-accessed by the admin Discovery tab. Isolate the filter // on both axes — a third-party callback that THROWS must not 500 discovery.json, // and one that returns a NON-ARRAY must not corrupt the shape — falling back to // the valid, unfiltered envelope in either case. try { $filtered = apply_filters( 'agentimus_envelope', $envelope, $this->registry ); } catch ( \Throwable $e ) { return $envelope; } return is_array( $filtered ) ? $filtered : $envelope; } /** * The canonical JSON Schema URL, filterable but always a real URL string — an * empty / non-string filter return falls back to the built-in canonical URL so * the `$schema` key never ends up null or malformed in discovery.json (or on the * admin About tab, which reuses this). * * @return string */ public static function schema_url() { $default = self::SCHEMA_BASE . '/discovery/' . self::SPEC_VERSION . '/discovery.schema.json'; $url = esc_url_raw( (string) apply_filters( 'agentimus_schema_url', $default ) ); return '' !== $url ? $url : $default; } /** * Every collected Resource, absolutized, BEFORE owner suppression is applied. * For admin curation UIs that must show suppressed Resources so the owner can * re-enable them — NOT for serving (the served document uses build()). * * @return array[] */ public function all_resources() { $this->registry->collect(); return array_map( array( $this, 'absolutize_resource' ), array_values( $this->registry->resources() ) ); } /** * Every Resource that may actually be PUBLISHED — the single source of truth for what any * served surface is allowed to say. * * Owner authority / the publication boundary (spec §04, M14): a provider proposes, the owner * disposes. Filtering before any derivation keeps suppressed Resources out of apis[], agents[], * capabilities AND resources[]. * * This USED to live inline in build(), and the comment there claimed it covered "every served * surface". It did not. {@see McpSurface} collected straight from the registry, so an owner who * suppressed a Resource still had it published — with full tool schemas — at the public * /.well-known/mcp.json. The rule is only real if every served surface reads it from one place, * so this is that place. build() and McpSurface both go through here. * * NOT for the admin (see {@see all_resources()}): the Discovery screen shows suppressed * Resources, flagged, so the owner can re-enable them. * * @return array[] */ public function published_resources() { $this->registry->collect(); $resources = array_values( $this->registry->resources() ); $suppressed = $this->suppressed_ids(); $kept = array(); foreach ( $resources as $resource ) { // The OWNER's decision. if ( in_array( $resource['id'], $suppressed, true ) ) { continue; } // The PROVIDER's decision: a resource nobody anonymous could ever use. See // Resource::normalize()'s `public` field, and AbilitiesApi, which sets it false for // abilities that all require sign-in. if ( isset( $resource['public'] ) && ! (bool) $resource['public'] ) { continue; } // THE SITE'S OWN ANSWER, and the newest of the three. An endpoint published // as needing no sign-in is a claim about somebody else's door; this drops // the ones a real anonymous request could not open. Nothing is fetched // here — {@see Reachability} decides in the background and this reads its // verdict. $had_addresses = ! empty( $resource['endpoints'] ); $resource = self::proved_endpoints( $resource ); if ( $had_addresses && self::says_nothing( $resource ) ) { continue; } $kept[] = $resource; } return $kept; } /** * The same resource with any address we could not prove open struck out. * * ⭐ Per ENDPOINT, not per resource: a plugin naming two addresses, one open * and one locked down after an update, keeps the one that still answers. * * @param array $resource One collected resource. * @return array */ private static function proved_endpoints( array $resource ) { if ( empty( $resource['endpoints'] ) || ! is_array( $resource['endpoints'] ) ) { return $resource; } $resource['endpoints'] = array_values( array_filter( $resource['endpoints'], static function ( $endpoint ) use ( $resource ) { if ( ! Reachability::claims_to_be_open( $endpoint, $resource ) ) { return true; // We are not claiming it is open, so there is nothing to prove. } return Reachability::may_advertise( isset( $endpoint['url'] ) ? $endpoint['url'] : '' ); } ) ); return $resource; } /** * Whether a resource has anything left worth publishing. * * ⚠️ Asked ONLY of a resource that arrived with addresses, so a group that * never had one — every abilities group is exactly that — is never dropped by * it. A plugin that describes itself without naming a door made that choice * itself, and it is not ours to overrule. * * @param array $resource One collected resource, after {@see proved_endpoints()}. * @return bool */ private static function says_nothing( array $resource ) { if ( ! empty( $resource['endpoints'] ) ) { return false; } if ( ! empty( $resource['tools'] ) || ! empty( $resource['abilities'] ) ) { return false; } return empty( $resource['agent']['skills'] ); } /** * Resource ids the site owner has suppressed from all served output — the * publication boundary defined in spec §04 ("Owner authority"). * * @return string[] */ public function suppressed_ids() { return array_values( (array) $this->settings->get( 'suppressed_resources', array() ) ); } /* ---------------------------------------------------------------------- * * MCP surface (delegated to McpSurface, served outside the core) * ---------------------------------------------------------------------- */ /** * The MCP projector, built lazily and shared across the mcp_* delegations. * * @return McpSurface */ private function mcp_builder() { if ( null === $this->mcp_surface_builder ) { $this->mcp_surface_builder = new McpSurface( $this->settings, $this->registry, $this ); } return $this->mcp_surface_builder; } /** * The secondary-document serializer (agent-card, api-catalog, oauth-prm, skills), * built lazily. * * @return WellKnownDocs */ private function docs_builder() { if ( null === $this->docs ) { $this->docs = new WellKnownDocs( $this->settings, $this->registry, $this ); } return $this->docs; } /** * The MCP/tools surface (tools + descriptor), for /.well-known/mcp.json and the * admin Discovery screen. * * @return array{tools:array[],mcp:array} */ public function mcp_surface() { return $this->mcp_builder()->mcp_surface(); } /** * The /.well-known/mcp.json manifest. * * @return string */ public function mcp_json() { return $this->mcp_builder()->mcp_json(); } /** * The standard single-server card at /.well-known/mcp/server-card.json, or '' * when no MCP server is advertised. * * @return string */ public function mcp_server_card_json() { return $this->mcp_builder()->mcp_server_card_json(); } /** * The per-server card at /.well-known/mcp/{id}/server-card.json, or '' when no * such server. * * @param string $id Server id. * @return string */ public function mcp_server_card_json_for( $id ) { return $this->mcp_builder()->mcp_server_card_json_for( $id ); } /* ---------------------------------------------------------------------- * * Sections * ---------------------------------------------------------------------- */ /** * Site identity block — also read by {@see McpSurface} and the document * serializers, so it is public. * * @return array */ public function site() { return array( 'name' => $this->clean( $this->settings->display_name() ), 'url' => home_url( '/' ), 'description' => $this->clean( get_bloginfo( 'description' ) ), 'lang' => get_bloginfo( 'language' ), 'logo' => (string) get_site_icon_url(), ); } /** * Decode HTML entities and strip tags so a value reads as clean plain text in the * JSON output. get_bloginfo() can hand back entity-encoded text (e.g. an "&" comes * through as "&"), which would otherwise appear literally in the document. * * @param string $value Possibly entity-encoded text. * @return string */ private function clean( $value ) { return trim( html_entity_decode( wp_strip_all_tags( (string) $value ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); } /** @return array The "whoami" — the person/org behind the site. */ private function identity() { $type = (string) $this->settings->identity( 'entity_type', 'Person' ); $contacts = array(); // Opt-in only — never leak the site's admin_email into a public document. $email = (string) $this->settings->identity( 'contact_email', '' ); if ( '' !== $email && is_email( $email ) ) { $contacts[] = array( 'type' => 'email', 'value' => 'mailto:' . $email ); } $identity = array( 'type' => 'Person' === $type ? 'person' : 'organization', 'name' => $this->settings->display_name(), 'role' => (string) $this->settings->identity( 'role', '' ), 'about' => (string) $this->settings->identity( 'about', '' ), 'url' => home_url( '/' ), 'same_as' => array_values( (array) $this->settings->identity( 'same_as', array() ) ), 'contacts' => $contacts, ); // Optional disambiguation — included only when the owner sets it, so an // unconfigured site's document is unchanged (additive, schema-permissive). $not = (string) $this->settings->identity( 'not_description', '' ); if ( '' !== $not ) { $identity['not_description'] = $not; } $audience = (string) $this->settings->identity( 'audience', '' ); if ( '' !== $audience ) { $identity['audience'] = $audience; } $projects = $this->projects(); if ( ! empty( $projects ) ) { $identity['projects'] = $projects; } return $identity; } /** * The software the owner listed as theirs, each row with only the fields they * filled. Additive like `not_description`: a site that lists none keeps the * document it had. * * @return array[] */ private function projects() { $out = array(); foreach ( (array) $this->settings->identity( 'projects', array() ) as $project ) { $name = is_array( $project ) && isset( $project['name'] ) ? $this->clean( $project['name'] ) : ''; if ( '' === $name ) { continue; } $row = array( 'name' => $name ); foreach ( array( 'description', 'version' ) as $field ) { $value = isset( $project[ $field ] ) ? $this->clean( $project[ $field ] ) : ''; if ( '' !== $value ) { $row[ $field ] = $value; } } foreach ( array( 'url', 'repo', 'download' ) as $field ) { $link = isset( $project[ $field ] ) ? esc_url_raw( (string) $project[ $field ] ) : ''; if ( '' !== $link ) { $row[ $field ] = $link; } } $out[] = $row; } return $out; } /** * Site-wide content/document links an agent can fetch: the sitemap, robots, the * RSS feed, the llms.txt pair, and humans.txt / security.txt when present. This * is the "map, not territory" surface — every entry is a link to a standard, * separately-served document — and it's filterable so a site can add ones the * plugin can't auto-detect (OpenAPI, a web-app manifest, …) without a code change. * * @return array name => URL. */ private function documents() { $docs = array( 'sitemap' => $this->sitemap_url(), 'robots' => home_url( '/robots.txt' ), 'feed' => get_feed_link(), // OpenAPI 3.1 description of the existing public REST read API (always served). 'openapi' => home_url( '/.well-known/openapi.json' ), // How agents authenticate here — generated from live config (always served). 'auth' => home_url( '/auth.md' ), ); if ( $this->settings->enabled( 'enable_llms_txt' ) ) { $docs['llms'] = home_url( '/llms.txt' ); } if ( $this->settings->enabled( 'enable_llms_full' ) ) { $docs['llms_full'] = home_url( '/llms-full.txt' ); } if ( $this->settings->enabled( 'enable_changes' ) ) { $docs['changes'] = home_url( '/agentimus-changes.json' ); } if ( file_exists( \Agentimus\Paths::site_root() . 'humans.txt' ) ) { $docs['humans'] = home_url( '/humans.txt' ); } if ( file_exists( \Agentimus\Paths::site_root() . '.well-known/security.txt' ) || isset( $this->registry->well_known()['security.txt'] ) ) { $docs['security'] = home_url( '/.well-known/security.txt' ); } /** * Filter the discovery `documents` map. Add a standard document the plugin * can't auto-detect — e.g. an OpenAPI description or a web-app manifest: * * add_filter( 'agentimus_documents', function ( $docs ) { * $docs['openapi'] = home_url( '/wp-json/myplugin/v1/openapi.json' ); * return $docs; * } ); * * Empty values are dropped after the filter. * * @param array $docs name => URL. * @param Registry $registry The collector. */ $docs = (array) apply_filters( 'agentimus_documents', $docs, $this->registry ); return array_filter( $docs ); } /** * Every `/.well-known/*` resource this site exposes: real files on disk, * plugin-managed docs, and the ones this plugin generates. * * @return array[] */ private function well_known_index() { $index = array(); // 1. Generated by this plugin. foreach ( array( 'discovery.json', 'agent-card.json', 'agent.json', 'mcp.json', 'openapi.json', 'api-catalog', 'agent-skills' ) as $name ) { $index[ $name ] = array( 'name' => $name, 'url' => home_url( '/.well-known/' . $name ), 'source' => 'generated' ); } // The signing key directory is listed only when signing is actually on. $signer = new Signer( $this->settings ); if ( $signer->enabled() ) { $index[ Signer::DIRECTORY ] = array( 'name' => Signer::DIRECTORY, 'url' => home_url( '/.well-known/' . Signer::DIRECTORY ), 'source' => 'generated' ); } // RFC 9728 protected-resource metadata is listed when any auth server backs // it — the owner-declared external one, or the built-in OAuth server that // ships with the MCP server (the flat doc serves either; see WellKnownDocs). if ( '' !== trim( (string) $this->settings->get( 'oauth_auth_server', '' ) ) || $this->settings->enabled( 'enable_mcp_server' ) ) { $index['oauth-protected-resource'] = array( 'name' => 'oauth-protected-resource', 'url' => home_url( '/.well-known/oauth-protected-resource' ), 'source' => 'generated' ); } // 2. Plugin-managed providers. foreach ( $this->registry->well_known() as $name => $def ) { $index[ $name ] = array( 'name' => $name, 'url' => home_url( '/.well-known/' . $name ), 'source' => 'managed' ); } // 3. Real files on disk (authoritative — server serves these directly). $dir = \Agentimus\Paths::site_root() . '.well-known/'; if ( is_dir( $dir ) ) { foreach ( (array) glob( $dir . '*' ) as $path ) { if ( is_file( $path ) ) { $name = basename( $path ); $index[ $name ] = array( 'name' => $name, 'url' => home_url( '/.well-known/' . $name ), 'source' => 'file' ); } } } // Annotate each recognised name with the standard that governs it, so a // consumer knows what the document IS — not just that it exists. Unknown // names get no `spec` rather than a fabricated one. $specs = $this->well_known_specs(); foreach ( $index as $name => &$entry ) { if ( isset( $specs[ $name ] ) ) { $entry['spec'] = $specs[ $name ]; } } unset( $entry ); return array_values( $index ); } /** * Recognised /.well-known names → the standard that governs each, so the index * can tell a consumer what every document IS. Filterable, so a provider can * label a well-known it serves; unknown names are simply absent (no fabrication). * * @return array name => spec label. */ private function well_known_specs() { $specs = array( // This protocol's own documents. 'discovery.json' => 'WP Discovery', 'agent-card.json' => 'A2A', 'agent.json' => 'A2A (legacy)', 'mcp.json' => 'MCP (experimental)', 'mcp/server-card.json' => 'MCP Server Card', // Same label WellKnownDocs uses when it links this document. 'openapi.json' => 'OpenAPI 3.1', 'agent-skills' => 'Agent Skills (agentskills.io)', 'agent-skills/index.json' => 'Agent Skills', 'api-catalog' => 'RFC 9727', 'http-message-signatures-directory' => 'Web Bot Auth (HTTP Message Signatures)', // Security. 'security.txt' => 'RFC 9116', 'mta-sts.txt' => 'RFC 8461', // Identity, auth & federation. 'openid-configuration' => 'OpenID Connect Discovery', 'oauth-authorization-server' => 'RFC 8414', 'oauth-protected-resource' => 'RFC 9728', 'jwks.json' => 'RFC 7517', 'webfinger' => 'RFC 7033', 'host-meta' => 'RFC 6415', 'openid-federation' => 'OpenID Federation', 'nodeinfo' => 'NodeInfo', // Apps & deep linking. 'assetlinks.json' => 'Digital Asset Links', 'apple-app-site-association' => 'Apple Universal Links', // Web platform & privacy. 'change-password' => 'W3C Change Password URL', 'gpc.json' => 'Global Privacy Control', 'dnt-policy.txt' => 'EFF Do Not Track', 'traffic-advice' => 'Private Prefetch Proxy', // Calendars & contacts. 'caldav' => 'RFC 6764', 'carddav' => 'RFC 6764', // Legacy. 'ai-plugin.json' => 'OpenAI Plugin (legacy)', ); /** * Filter the /.well-known name → governing-spec labels. * * @param array $specs name => spec label. */ return (array) apply_filters( 'agentimus_well_known_specs', $specs ); } /** * Flatten resource endpoints into the API index. * * @param array[] $resources Resources. * @return array[] */ private function apis( $resources ) { $apis = array(); foreach ( $resources as $resource ) { foreach ( $resource['endpoints'] as $endpoint ) { if ( ! in_array( $endpoint['type'], Resource::API_TYPES, true ) ) { continue; } // Per-endpoint auth wins when declared (e.g. a public Store API // alongside an authenticated admin API on the same resource); // otherwise fall back to the resource-level scheme. $auth_type = '' !== $endpoint['auth'] ? $endpoint['auth'] : $resource['auth']['type']; $apis[] = array( 'id' => $resource['id'], 'type' => $endpoint['type'], 'base' => $this->absolute( $endpoint['url'] ), 'schema' => isset( $resource['schemas'][0] ) ? $this->absolute( $resource['schemas'][0] ) : '', 'auth' => array( 'type' => $auth_type, 'docs' => $this->auth_docs( $auth_type, $resource['auth'] ), ), ); } } return $apis; } /** * Resources that carried an agent-card fragment. * * @param array[] $resources Resources. * @return array[] */ private function agents( $resources ) { $agents = array(); foreach ( $resources as $resource ) { if ( empty( $resource['agent'] ) ) { continue; } $agent = $resource['agent']; $agent['id'] = $resource['id']; $agent['card'] = home_url( '/.well-known/agent-card.json#' . $resource['id'] ); $agent['endpoint'] = $this->absolute( isset( $agent['endpoint'] ) ? $agent['endpoint'] : '' ); $agents[] = $agent; } return $agents; } /** * Deduped union of every resource's capabilities. * * @param array[] $resources Resources. * @return string[] */ private function capabilities( $resources ) { $caps = array(); foreach ( $resources as $resource ) { $caps = array_merge( $caps, $resource['capabilities'] ); } return array_values( array_unique( $caps ) ); } /** * The Agent Skills index at /.well-known/agent-skills/index.json, in the * agentskills.io discovery format — lists the site's own SKILL.md. * * @return string JSON. */ public function agent_skills_index_json() { return $this->docs_builder()->agent_skills_index_json(); } /** * The site packaged as an agentskills.io Agent Skill, at * /.well-known/agent-skills/{name}/SKILL.md. * * @return string Markdown. */ public function skill_md() { return ( new SkillMd( $this->settings, $this ) )->markdown(); } /** * The markdown listing at the bare /.well-known/agent-skills path. * * @return string Markdown. */ public function skill_directory_md() { return ( new SkillMd( $this->settings, $this ) )->directory_markdown(); } /** * RFC 9728 OAuth Protected Resource Metadata, or '' when no auth server is set. * * @return string */ public function oauth_protected_resource_json() { return $this->docs_builder()->oauth_protected_resource_json(); } /** * RFC 9728 metadata for the MCP endpoint under the built-in auth server. * * @return string JSON, or '' when the MCP server is off. */ public function oauth_mcp_protected_resource_json() { return $this->docs_builder()->oauth_mcp_protected_resource_json(); } /** * RFC 8414 metadata for the built-in auth server. * * @return string JSON, or '' when the MCP server is off. */ public function oauth_authorization_server_json() { return $this->docs_builder()->oauth_authorization_server_json(); } /** @return array Minimal trust surface (v2: jwks_uri, signed cards, DID). */ private function trust() { // State authenticity explicitly. By default a 1.0 document is NOT signed, so // say so rather than leave an empty {}. When response signing is enabled // (Web Bot Auth), advertise that the discovery docs are signed and where the // verification keys live. $trust = array( 'signed' => false ); $signer = new Signer( $this->settings ); if ( $signer->enabled() ) { $trust['signed'] = true; $trust['signature_alg'] = 'ed25519'; $trust['jwks_uri'] = $signer->directory_url(); } if ( file_exists( \Agentimus\Paths::site_root() . '.well-known/security.txt' ) || isset( $this->registry->well_known()['security.txt'] ) ) { $trust['security_txt'] = home_url( '/.well-known/security.txt' ); } $policy = get_privacy_policy_url(); if ( $policy ) { $trust['policy'] = $policy; } return $trust; } /* ---------------------------------------------------------------------- * * Helpers * ---------------------------------------------------------------------- */ /** * Absolutize a stored resource's URLs against the site for output. * * @param array $resource Normalized resource. * @return array */ private function absolutize_resource( $resource ) { foreach ( $resource['endpoints'] as &$endpoint ) { $endpoint['url'] = $this->absolute( $endpoint['url'] ); } unset( $endpoint ); $resource['schemas'] = array_map( array( $this, 'absolute' ), $resource['schemas'] ); $resource['docs'] = $this->absolute( $resource['docs'] ); $resource['auth']['oidc'] = $this->absolute( $resource['auth']['oidc'] ); $resource['auth']['docs'] = $this->absolute( $resource['auth']['docs'] ); return $resource; } /** * A resource shaped for the SERVED document: absolutized, with internal-only fields removed. * * `public` is a publication-boundary flag consumed by {@see published_resources()}, not part of * the wire contract. discovery.json ships a canonical JSON Schema, so an extra key would make a * strict (`additionalProperties:false`) consumer reject the document. The admin path * ({@see all_resources()}) deliberately keeps `public` — the Discovery screen needs it to flag * held-back resources — so it is stripped HERE, on the served path, not in absolutize_resource(). * * @param array $resource A published resource. * @return array */ private function wire_resource( $resource ) { $resource = $this->absolutize_resource( $resource ); unset( $resource['public'] ); // `agent` is optional and, when present, an object (an AgentCard). A // resource with no agent carries an empty PHP array, which encodes as // `[]` and fails the schema — so it leaves the document instead. if ( empty( $resource['agent'] ) ) { unset( $resource['agent'] ); } // Each tool's `kind` (the admin's tool/document split) and `uri` (the // admin's link to a document) are internal for the same reason `public` // is, so they come off here rather than in normalize(). if ( isset( $resource['tools'] ) && is_array( $resource['tools'] ) ) { // A tool the vendor never advertised does not reach the served document // — the same publication boundary `public` draws around a whole // resource, drawn around one tool. The admin path keeps them, so the // owner still sees everything their site holds. $advertised = array_filter( (array) $resource['tools'], static function ( $tool ) { return ! isset( $tool['public'] ) || (bool) $tool['public']; } ); $resource['tools'] = array_values( array_map( static function ( $tool ) { unset( $tool['kind'], $tool['uri'], $tool['public'] ); // Objects on the wire, even when empty: a tool that takes no // input has an empty PHP array here, which encodes as `[]` // and fails the schema. Cast, as the envelope does for trust. foreach ( array( 'inputSchema', 'outputSchema', 'annotations' ) as $key ) { if ( isset( $tool[ $key ] ) && array() === $tool[ $key ] ) { $tool[ $key ] = (object) array(); } } return $tool; }, $advertised ) ); } return $resource; } /** * Resolve a possibly-relative URL against the site root. * * @param string $url URL or "/path". * @return string */ private function absolute( $url ) { $url = (string) $url; if ( '' === $url ) { return ''; } if ( '/' === $url[0] ) { return home_url( $url ); } return $url; } /** * The public URL of a /.well-known doc, but ONLY when the site actually serves * it (a real file on disk or a registered provider) — so the discovery document * never advertises a dead link. * * @param string $name Doc name, e.g. "oauth-protected-resource". * @return string Absolute URL, or '' if not served here. */ private function well_known_if_present( $name ) { if ( file_exists( \Agentimus\Paths::site_root() . '.well-known/' . $name ) || isset( $this->registry->well_known()[ $name ] ) ) { return home_url( '/.well-known/' . $name ); } return ''; } /** * The best "how to authenticate" link for an API endpoint, preferring standards * over bespoke strings: * 1. the provider's own auth docs, else * 2. a provider-declared OpenID Connect configuration, else * 3. the site's standard auth-metadata well-known for the scheme — OAuth 2.0 * Authorization Server Metadata (RFC 8414) or OpenID Connect Discovery — * but only when it is actually published here. * * @param string $type Auth scheme (e.g. oauth, oidc). * @param array $auth The resource's raw (relative) auth block. * @return string Absolute URL, or ''. */ private function auth_docs( $type, $auth ) { $docs = $this->absolute( isset( $auth['docs'] ) ? $auth['docs'] : '' ); if ( '' !== $docs ) { return $docs; } $oidc = $this->absolute( isset( $auth['oidc'] ) ? $auth['oidc'] : '' ); if ( '' !== $oidc ) { return $oidc; } $map = array( 'oauth' => 'oauth-authorization-server', 'oauth2' => 'oauth-authorization-server', 'oidc' => 'openid-configuration', ); $key = strtolower( (string) $type ); return isset( $map[ $key ] ) ? $this->well_known_if_present( $map[ $key ] ) : ''; } /** * The detected sitemap URL (core or a known SEO plugin), or '' if none. * * @return string */ private function sitemap_url() { return \Agentimus\Sitemap::url(); } }