<?php

declare(strict_types=1);

require_once dirname(__DIR__) . '/license/CoreAttributionPolicy.php';

/**
 * APO-198 — MCP server adapter.
 *
 * Implements the Model Context Protocol (JSON-RPC 2.0 over Streamable HTTP)
 * as a thin translation layer on top of the action catalog substrate.
 *
 * Method dispatch:
 *   - initialize       — handshake (protocol version + capabilities)
 *   - initialized      — client ready signal (no-op response)
 *   - ping             — keepalive
 *   - tools/list       — manifest → MCP tool definitions (filtered by exposeToMcp:true + caller scopes)
 *   - tools/call       — invoke a MyronAction via the dispatcher; return CallToolResult
 *   - resources/list   — exposes mcp:// resources (snapshot + compatibility docs + manifest)
 *   - resources/read   — fetch a resource by URI
 *
 * Auth: hosted MCP operational calls require a durable bearer/OAuth caller.
 * Lifecycle sessions remain available for clients that use initialize plus the
 * initialized notification, but discovery and tool calls do not depend on that
 * ephemeral runtime state. Action execution still delegates to
 * MyronActionDispatcher for business auth, audit, scope, and rate-limit
 * behavior.
 *
 * Locked: full-parity-mcp-2026-05-10.md §9.
 */

require_once __DIR__ . '/SessionStore.php';
require_once __DIR__ . '/OperatingContract.php';
require_once dirname(__DIR__) . '/license/EntitlementGate.php';

final class MyronMcpServer
{
    public const PROTOCOL_VERSION = '2025-06-18';
    private const SESSION_HEADER = 'Mcp-Session-Id';

    /** @var array<string, string> */
    private array $responseHeaders = [];

    public function __construct(
        private readonly MyronActionRegistry $registry,
        private readonly MyronApiAuthorization $auth,
        private readonly MyronActionDispatcher $dispatcher,
        private readonly MyronSystemSnapshotService $snapshot,
        private readonly MyronCompatibilityDocumentService $compatibility,
        private readonly ?MyronMcpSessionStore $sessions = null,
    ) {
    }

    /**
     * Dispatch a single JSON-RPC 2.0 request.
     *
     * @param array<string, string|null> $headers
     * @param array<string, mixed>       $request  JSON-RPC envelope: {jsonrpc, id, method, params}
     * @return array<string, mixed>                JSON-RPC response envelope
     */
    public function handle(array $headers, array $request): array
    {
        $this->responseHeaders = [];
        $id = $request['id'] ?? null;
        $method = (string) ($request['method'] ?? '');
        $params = is_array($request['params'] ?? null) ? (array) $request['params'] : [];

        if ($method === '') {
            return self::errorResponse($id, -32600, 'Invalid request: missing method.');
        }

        // notifications/* methods do NOT return a response per JSON-RPC; the
        // transport layer decides whether to emit. Our adapter responds with
        // a result for everything except initialized + notifications/* which
        // get an empty 204-equivalent payload from the transport.
        try {
            $result = match ($method) {
                'initialize'                => $this->handleInitialize($headers, $params),
                'initialized', 'notifications/initialized' => $this->handleInitialized($headers),
                'notifications/cancelled'   => null,
                'ping'                      => self::emptyResult(),
                'tools/list'                => $this->handleToolsList($headers, $params),
                'tools/call'                => $this->handleToolsCall($headers, $params),
                'resources/list'            => $this->handleResourcesList($headers),
                'resources/read'            => $this->handleResourcesRead($headers, $params),
                default                     => throw new MyronMcpMethodNotFound($method),
            };
            if ($result === null) {
                // No response for notifications.
                return [];
            }
            return self::successResponse($id, $result);
        } catch (MyronMcpMethodNotFound $e) {
            return self::errorResponse($id, -32601, 'Method not found: ' . $e->getMessage());
        } catch (MyronMcpProtocolError $e) {
            return self::errorResponse($id, $e->jsonRpcCode, $e->getMessage(), $e->data);
        } catch (Throwable $e) {
            return self::errorResponse($id, -32603, 'Internal error: ' . $e->getMessage(), ['code' => 'MCP_INTERNAL_ERROR']);
        }
    }

    /**
     * @return array<string, mixed>
     */
    private function handleInitialize(array $headers, array $params): array
    {
        $caller = $this->requireBearerCaller($headers);
        $this->assertEntitled('mcp.site_operations');
        // Protocol version negotiation: accept any version the client offers;
        // respond with our supported version. A real implementation would
        // reject incompatible versions.
        $clientProtocolVersion = (string) ($params['protocolVersion'] ?? '');
        if ($clientProtocolVersion !== '' && $clientProtocolVersion !== self::PROTOCOL_VERSION) {
            // We're permissive here; a stricter check could reject.
        }
        $appVersion = function_exists('myronAppVersion')
            ? (string) myronAppVersion()
            : (defined('MyronReleaseMetadata::VERSION') ? MyronReleaseMetadata::VERSION : 'unknown');
        $sessionId = $this->sessionStore()->create($caller);
        $this->responseHeaders[self::SESSION_HEADER] = $sessionId;
        return [
            'protocolVersion' => self::PROTOCOL_VERSION,
            'capabilities' => [
                'tools'         => ['listChanged' => false],
                'resources'     => ['listChanged' => false, 'subscribe' => false],
            ],
            'serverInfo' => [
                'name'    => 'MyronCMS',
                'version' => $appVersion,
            ],
            'instructions' => MyronMcpOperatingContract::instructions(),
        ];
    }

    private function handleInitialized(array $headers): ?array
    {
        $caller = $this->requireBearerCaller($headers);
        $sessionId = $this->extractSessionId($headers);
        if ($sessionId === '') {
            throw new MyronMcpProtocolError(-32000, 'MCP session required before initialized notification.', ['code' => 'MCP_SESSION_REQUIRED']);
        }
        try {
            $this->sessionStore()->markInitialized($sessionId, $caller);
        } catch (Throwable $e) {
            throw new MyronMcpProtocolError(-32000, $e->getMessage(), ['code' => 'MCP_SESSION_INVALID']);
        }
        return null;
    }

    /**
     * @param array<string, string|null> $headers
     * @return array<string, mixed>
     */
    private function handleToolsList(array $headers, array $params): array
    {
        $caller = $this->requireOperationalCaller($headers);
        $tools = [];
        foreach ($this->registry->all() as $action) {
            $meta = $action->metadata();
            if (!$meta->exposeToMcp) continue;
            if ($this->isMcpHiddenByPolicy($meta)) continue;
            if (!$this->actionAllowedByEntitlement($meta)) continue;
            if ($meta->requiredScope !== null && !$caller->holdsScope($meta->requiredScope)) {
                continue;
            }
            $toolName = self::toolNameFor($meta);
            $tools[] = [
                'name'        => $toolName,
                'description' => MyronMcpOperatingContract::description($meta),
                'inputSchema' => self::normalizeToolInputSchema($meta->inputSchema),
                'annotations' => MyronMcpOperatingContract::annotations($meta, $toolName),
                '_meta' => MyronMcpOperatingContract::meta($meta),
            ];
        }
        if ($caller->holdsScope(MyronApiScopes::DESIGN_READ)) {
            $tools[] = [
                'name' => 'list_creative_library',
                'description' => 'List MyronCMS creative library entries, optionally filtered by category.',
                'inputSchema' => self::normalizeToolInputSchema([
                    'type' => 'object',
                    'properties' => [
                        'category' => [
                            'type' => 'string',
                            'enum' => ['scene', 'motion', 'hero', 'navigation', 'button'],
                        ],
                    ],
                    'additionalProperties' => false,
                ]),
                'annotations' => [
                    'title' => 'List Creative Library',
                    'readOnlyHint' => true,
                    'destructiveHint' => false,
                    'idempotentHint' => true,
                ],
                '_meta' => ['com.myroncms/operating' => ['audienceClass' => 2, 'sideEffects' => [], 'preconditions' => [], 'postconditions' => []]],
            ];
        }
        usort($tools, static fn (array $a, array $b): int => strcmp($a['name'], $b['name']));
        return ['tools' => $tools];
    }

    /**
     * Normalize a tool inputSchema for MCP `tools/list` emission.
     *
     * JSON Schema requires `properties` to be an object. PHP's json_encode
     * serializes an empty array as `[]`, so a paramless action declaring
     * `'properties' => []` is emitted as `"properties":[]`. JSON-Schema-strict
     * MCP clients (e.g. Claude Code) reject that and discard the ENTIRE
     * tools/list (all-or-nothing), so no tool registers — even valid ones.
     *
     * Coerce an empty `properties` to an object so it serializes as `{}`.
     * Populated schemas and array-valued keys (`required`, `enum`) are left
     * untouched; do NOT use JSON_FORCE_OBJECT, which would corrupt those.
     *
     * @param array<string, mixed> $schema
     * @return array<string, mixed>
     */
    public static function normalizeToolInputSchema(array $schema): array
    {
        if (($schema['properties'] ?? null) === []) {
            $schema['properties'] = new \stdClass();
        }
        return $schema;
    }

    /**
     * @param array<string, string|null> $headers
     * @return array<string, mixed>
     */
    private function handleToolsCall(array $headers, array $params): array
    {
        $caller = $this->requireOperationalCaller($headers);
        $toolName = (string) ($params['name'] ?? '');
        $arguments = is_array($params['arguments'] ?? null) ? (array) $params['arguments'] : [];
        if ($toolName === '') {
            throw new MyronMcpProtocolError(-32602, 'Invalid params: tool name is required.', ['code' => 'MCP_TOOL_NAME_MISSING']);
        }

        if ($toolName === 'list_creative_library') {
            if (!$caller->holdsScope(MyronApiScopes::DESIGN_READ)) {
                throw new MyronMcpProtocolError(-32603, 'Caller does not hold the required scope: ' . MyronApiScopes::DESIGN_READ . '.', ['code' => 'MCP_AUTHZ_DENIED']);
            }
            $category = trim((string) ($arguments['category'] ?? ''));
            $entries = $category !== ''
                ? myronCreativeLibraryCatalog()->listByCategory($category)
                : myronCreativeLibraryCatalog()->list();
            $result = array_map(
                static fn (array $entry): array => [
                    'id' => (string) ($entry['id'] ?? ''),
                    'label' => (string) ($entry['label'] ?? ''),
                    'description' => (string) ($entry['description'] ?? ''),
                    'category' => (string) ($entry['category'] ?? ''),
                    'tier' => (string) ($entry['tier'] ?? ''),
                    'compatibleSectionKeys' => array_values(array_map('strval', is_array($entry['compatibleSectionKeys'] ?? null) ? $entry['compatibleSectionKeys'] : [])),
                    'supportedParams' => array_values(array_filter(is_array($entry['supportedParams'] ?? null) ? $entry['supportedParams'] : [], 'is_array')),
                ],
                $entries
            );

            return [
                'content' => [
                    [
                        'type' => 'text',
                        'text' => json_encode(['entries' => $result], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT),
                    ],
                ],
                'isError' => false,
                '_meta' => [
                    'actionId' => null,
                    'exposeToMcp' => true,
                ],
            ];
        }

        $action = $this->findActionByToolName($toolName);
        if ($action === null) {
            throw new MyronMcpMethodNotFound('tool: ' . $toolName);
        }
        $meta = $action->metadata();
        if (!$meta->exposeToMcp || $this->isMcpHiddenByPolicy($meta)) {
            throw new MyronMcpMethodNotFound('tool: ' . $toolName);
        }
        if (!$this->actionAllowedByEntitlement($meta)) {
            throw new MyronMcpProtocolError(-32603, 'Tool is not available for the current license.', ['code' => 'MCP_ENTITLEMENT_DENIED']);
        }
        if ($meta->domain === 'design' && in_array($meta->resource, ['custom-css', 'custom-interactions'], true) && MyronCoreAttributionPolicy::containsRemovalAttempt(json_encode($arguments, JSON_UNESCAPED_SLASHES) ?: '')) {
            throw new MyronMcpProtocolError(-32603, 'Core attribution removal is not supported by this license.', ['code' => 'MCP_ATTRIBUTION_PROTECTED']);
        }

        // Substitute URI placeholders ({name}) with values from arguments so
        // the dispatcher's URI-regex matcher receives a concrete URI. Without
        // this, the literal `{variantId}` token leaks into path-params and
        // overrides the real argument.
        $concreteUri = preg_replace_callback(
            '/\{([a-zA-Z_][a-zA-Z0-9_]*)\}/',
            static function (array $m) use (&$arguments): string {
                $key = $m[1];
                $value = $arguments[$key] ?? '';
                // After substitution, remove the key from arguments so it
                // doesn't double-merge as a body field after the dispatcher
                // re-extracts it from path-params.
                if (isset($arguments[$key])) {
                    // keep it in args too — the dispatcher will overlay
                    // path-params on top of body anyway. Either source is fine.
                }
                return rawurlencode((string) $value);
            },
            $meta->uri
        );
        $response = $this->dispatcher->dispatch($meta->method, $concreteUri, $headers, $arguments);
        $envelope = $response['envelope'] ?? [];
        $status   = (int) ($response['status'] ?? 500);

        // Map dispatcher errors to MCP shape.
        if ($status === 401 || $status === 403) {
            throw new MyronMcpProtocolError(-32603, (string) ($envelope['error']['message'] ?? 'Authorization denied.'), [
                'code' => 'MCP_AUTHZ_DENIED',
                'auditEventId' => $envelope['auditEventId'] ?? null,
            ]);
        }
        if ($status === 429) {
            throw new MyronMcpProtocolError(-32603, (string) ($envelope['error']['message'] ?? 'Rate limit exceeded.'), [
                'code'        => 'MCP_RATE_LIMITED',
                'retryAfter'  => 60,
                'auditEventId' => $envelope['auditEventId'] ?? null,
            ]);
        }

        // 4xx/5xx other than auth/rate become tool-level errors (isError:true).
        $isOk = (bool) ($envelope['ok'] ?? false);
        if ($isOk && $meta->domain === 'system' && $meta->resource === 'manifest' && $meta->verb === 'read') {
            $envelope['result'] = ['manifest' => $this->composeManifestPayload($caller)];
        }
        $content = [
            [
                'type' => 'text',
                'text' => $isOk
                    ? json_encode($envelope['result'] ?? [], JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT)
                    : (string) ($envelope['error']['message'] ?? 'Tool call failed.'),
            ],
        ];

        return [
            'content' => $content,
            'isError' => !$isOk,
            '_meta'   => [
                'auditEventId' => $envelope['auditEventId'] ?? null,
                'actionId'     => $envelope['actionId'] ?? null,
            ],
        ];
    }

    /**
     * @return array<string, mixed>
     */
    private function handleResourcesList(array $headers): array
    {
        $caller = $this->requireOperationalCaller($headers);
        return ['resources' => $this->resourcesForCaller($caller)];
    }

    /**
     * @param array<string, string|null> $headers
     * @return array<string, mixed>
     */
    private function handleResourcesRead(array $headers, array $params): array
    {
        $uri = (string) ($params['uri'] ?? '');
        $caller = $this->requireOperationalCaller($headers);
        $this->assertResourceReadable($caller, $uri);

        if ($uri === 'mcp://creative-component-sdk') {
            return [
                'contents' => [
                    [
                        'uri'      => $uri,
                        'mimeType' => 'text/markdown',
                        'text'     => $this->composeCreativeSdkDocument(),
                    ],
                ],
            ];
        }

        if ($uri === 'mcp://myroncms/design/jsonm-interchange') {
            return [
                'contents' => [
                    [
                        'uri'      => $uri,
                        'mimeType' => 'text/markdown',
                        'text'     => $this->composeJsonmInterchangeDocument(),
                    ],
                ],
            ];
        }

        if ($uri === 'mcp://myroncms/content/blog-post-authoring') {
            return [
                'contents' => [
                    [
                        'uri' => $uri,
                        'mimeType' => 'text/markdown',
                        'text' => $this->composeBlogPostAuthoringDocument(),
                    ],
                ],
            ];
        }

        if (!str_starts_with($uri, 'mcp://myroncms/')) {
            throw new MyronMcpProtocolError(-32602, 'Invalid params: resource URI must start with mcp://myroncms/', ['code' => 'MCP_RESOURCE_URI_INVALID']);
        }
        $relative = substr($uri, strlen('mcp://myroncms/'));

        $payload = match (true) {
            $relative === 'system/manifest' => $this->composeManifestPayload($caller),
            $relative === 'system/snapshot' => $this->scopeFilterSnapshot($this->snapshot->compose($caller->heldScopes), $caller),
            $relative === 'system/compatibility/configured' => $this->scopeFilterCompatibilityDocument($this->compatibility->configured(), $caller),
            str_starts_with($relative, 'system/compatibility/default') => $this->compatibility->default($this->resolveSiteTypeFromUri($uri)),
            default => throw new MyronMcpProtocolError(-32602, 'Resource not found: ' . $uri, ['code' => 'MCP_RESOURCE_NOT_FOUND']),
        };

        return [
            'contents' => [
                [
                    'uri'      => $uri,
                    'mimeType' => 'application/json',
                    'text'     => json_encode($payload, JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT),
                ],
            ],
        ];
    }

    private function composeCreativeSdkDocument(): string
    {
        $guidePath = dirname(__DIR__, 3) . '/docs/creative-component-sdk.md';
        $guide = is_file($guidePath) ? (string) file_get_contents($guidePath) : '';

        $motionProfiles = myronCreativeLibraryCatalog()->listByCategory('motion');
        $sceneBackgrounds = myronCreativeLibraryCatalog()->listByCategory('scene');
        $declaredDeps = myronCustomCssRepository()->creativeDepsSetting();

        $lines = [];
        $lines[] = '';
        $lines[] = '---';
        $lines[] = '';
        $lines[] = '## This Install Context';
        $lines[] = '';
        $lines[] = '### Available Motion Profiles';
        foreach ($motionProfiles as $profile) {
            $paramSummary = implode(', ', array_map(
                static fn (array $p): string => ($p['key'] ?? '') . ' (' . ($p['type'] ?? 'number') . ', default ' . (string) ($p['default'] ?? '') . ')',
                is_array($profile['supportedParams'] ?? null) ? $profile['supportedParams'] : []
            ));
            $lines[] = '- **`' . ($profile['id'] ?? '') . '`** — ' . ($profile['label'] ?? '') . ($paramSummary !== '' ? '. Params: ' . $paramSummary : '');
        }
        $lines[] = '';
        $lines[] = '### Available Scene Backgrounds';
        foreach ($sceneBackgrounds as $scene) {
            $paramSummary = implode(', ', array_map(
                static fn (array $p): string => ($p['key'] ?? '') . ' (' . ($p['type'] ?? 'number') . ', default ' . (string) ($p['default'] ?? '') . ')',
                is_array($scene['supportedParams'] ?? null) ? $scene['supportedParams'] : []
            ));
            $lines[] = '- **`' . ($scene['id'] ?? '') . '`** — ' . ($scene['label'] ?? '') . ' [' . ($scene['implementationType'] ?? '') . ']' . ($paramSummary !== '' ? '. Params: ' . $paramSummary : '');
        }
        $lines[] = '';
        $lines[] = '### Declared External Dependencies';
        if ($declaredDeps === []) {
            $lines[] = '_None declared. Operators can add external JS libraries through Design Studio > Custom Interactions._';
        } else {
            foreach ($declaredDeps as $dep) {
                $lines[] = '- **' . ($dep['name'] ?? 'unnamed') . '**: `' . ($dep['cdnUrl'] ?? '') . '`';
            }
        }
        $lines[] = '';
        $lines[] = '### Performance Budget';
        $lines[] = '- `tti_ms_max`: 2500';
        $lines[] = '- `initial_js_kb_max`: 30';
        $lines[] = '- `canvas_particles_max`: 60';
        $lines[] = '';
        $lines[] = '### Creative Runtime Version';
        $lines[] = '- Shipped with APO-210 (MyronCMS v0.14.x)';

        return $guide . implode("\n", $lines) . "\n";
    }

    private function composeBlogPostAuthoringDocument(): string
    {
        return implode("\n", [
            '# MCP Blog Post Authoring',
            '',
            'Use these MCP tools to create, enrich, and publish blog posts through the governed MyronCMS content API.',
            '',
            '## Recommended Flow',
            '',
            '1. Create a blog page with `content_page_create` using a blog/post template.',
            '2. Update page-level metadata with `content_page_update`; omitted fields are preserved.',
            '3. Write long-form body content with `content_page_body_set_markdown`.',
            '4. Assign categories with `content_page_categories_set`.',
            '5. Assign the author with `content_page_author_set`.',
            '6. Publish the post with `content_page_publish` when the draft is ready.',
            '',
            '## Related Read Tools',
            '',
            '- `content_page_get` returns the current page record and publish state.',
            '- `content_page_contract_get` returns the page/template authoring contract.',
            '- `content_page_categories_get` returns assigned categories for a page.',
            '- `content_page_author_get` returns assigned author metadata for a page.',
            '',
        ]) . "\n";
    }

    private function composeJsonmInterchangeDocument(): string
    {
        $root = dirname(__DIR__, 3);
        $tokenMapPath = $root . '/app/jsonm/maps/token-map.json';
        $tokenMap = is_file($tokenMapPath) ? json_decode((string) file_get_contents($tokenMapPath), true) : [];
        $mappings = is_array($tokenMap['mappings'] ?? null) ? $tokenMap['mappings'] : [];

        // Load current definition as the live example
        $exampleJson = '{}';
        try {
            // DesignSystemRegistry loaded by the app-local JSONM runtime.
            $reg = new \DesignSystemRegistry();
            if (method_exists($reg, 'ensureStorage')) {
                $reg->ensureStorage();
            }
            $current = (array) $reg->currentDefinition();
            $exampleDef = $reg->exportCurrentDefinition((string) ($current['id'] ?? ''), (string) ($current['name'] ?? ''));
            $exampleJson = (string) json_encode($exampleDef, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
        } catch (\Throwable) {
            // non-fatal — spec still renders without the example
        }

        // Build CSS var → JSONM path lookup for the translation section
        $varToPath = [];
        foreach ($mappings as $m) {
            if (is_array($m) && (string) ($m['cssVar'] ?? '') !== '') {
                $varToPath[(string) $m['cssVar']] = (string) ($m['jsonPath'] ?? '');
            }
        }
        $sampleMappings = array_slice($varToPath, 0, 8, true);

        $lines = [];
        $lines[] = '# JSONM Design System Interchange Specification';
        $lines[] = '';
        $lines[] = 'This document is the authoritative guide for producing a valid, importable JSONM design system file for a MyronCMS install. Read it in full before generating or translating a JSONM document.';
        $lines[] = '';
        $lines[] = '**Import endpoint:** `POST /api/v1/design/definitions/import` (base64-encode the JSONM file content)';
        $lines[] = '**Export endpoint:** `GET /api/v1/design/definitions/export-current` (returns the current install\'s full JSONM as structured data)';
        $lines[] = '';
        $lines[] = '---';
        $lines[] = '';
        $lines[] = '## 1. Top-Level Document Structure';
        $lines[] = '';
        $lines[] = 'A valid JSONM document is a JSON object with the following top-level fields:';
        $lines[] = '';
        $lines[] = '| Field | Type | Required | Notes |';
        $lines[] = '|---|---|---|---|';
        $lines[] = '| `schemaVersion` | string | **yes** | Must equal `"1"` |';
        $lines[] = '| `format` | string | **yes** | Must equal `"JSONM"` |';
        $lines[] = '| `id` | string | **yes** | Unique slug, e.g. `"my-brand-spring-2025"` |';
        $lines[] = '| `name` | string | **yes** | Human display name |';
        $lines[] = '| `kind` | string | **yes** | One of: `"baseline"`, `"named"`, `"preset"` — use `"named"` for operator-created definitions |';
        $lines[] = '| `description` | string | no | Optional description |';
        $lines[] = '| `corpus` | object | **yes** | See §1a |';
        $lines[] = '| `settings` | object | **yes** | See §1b |';
        $lines[] = '| `tokens` | object | **yes** | See §2 |';
        $lines[] = '| `fontRoles` | object | **yes** | See §6 |';
        $lines[] = '';
        $lines[] = '### §1a — corpus';
        $lines[] = '';
        $lines[] = '```json';
        $lines[] = '"corpus": {';
        $lines[] = '  "id": "activetwist-design-corpus/v1",';
        $lines[] = '  "sourceFiles": ["app/assets/runtime/css/tokens.css"]';
        $lines[] = '}';
        $lines[] = '```';
        $lines[] = '';
        $lines[] = '`corpus.id` must equal `"activetwist-design-corpus/v1"` exactly. `sourceFiles` must contain at least one entry.';
        $lines[] = '';
        $lines[] = '### §1b — settings';
        $lines[] = '';
        $lines[] = '```json';
        $lines[] = '"settings": {';
        $lines[] = '  "wcagPolicy": "AA",';
        $lines[] = '  "appearanceModes": ["light"],';
        $lines[] = '  "defaultMode": "light",';
        $lines[] = '  "systemFollow": false';
        $lines[] = '}';
        $lines[] = '```';
        $lines[] = '';
        $lines[] = '- `wcagPolicy`: `"AA"` or `"AAA"`';
        $lines[] = '- `appearanceModes`: array containing `"light"`, `"dark"`, or both';
        $lines[] = '- `defaultMode`: must be one of the values in `appearanceModes`';
        $lines[] = '- `systemFollow`: boolean — whether the UI follows OS appearance preference';
        $lines[] = '';
        $lines[] = '---';
        $lines[] = '';
        $lines[] = '## 2. Token Path Conventions';
        $lines[] = '';
        $lines[] = 'JSONM paths use dot notation: `tokens.{family}.{subpath}`. Each path maps to a CSS custom property on this install.';
        $lines[] = '';
        $lines[] = '**Path → CSS var examples from this install:**';
        $lines[] = '';
        foreach ($sampleMappings as $cssVar => $jsonPath) {
            $lines[] = '- `' . $jsonPath . '` → `' . $cssVar . '`';
        }
        $lines[] = '';
        $lines[] = 'The complete path vocabulary for this install has ' . count($varToPath) . ' mapped tokens. Every path that appears in the token map is **required** — the validator rejects a JSONM document that is missing any mapped path.';
        $lines[] = '';
        $lines[] = '---';
        $lines[] = '';
        $lines[] = '## 3. Valid Value Formats by Token Family';
        $lines[] = '';
        $lines[] = '| Family | Path shape | Expected value format | Examples |';
        $lines[] = '|---|---|---|---|';
        $lines[] = '| `space` | `tokens.space.scale.{key}` | CSS length: `px` or `rem` | `"0px"`, `"8px"`, `"1rem"` |';
        $lines[] = '| `space` | `tokens.space.aliases.{name}` | Scale key reference | `"sm"`, `"md"`, `"lg"` |';
        $lines[] = '| `size` | `tokens.size.control.{n}` | CSS length | `"32px"`, `"40px"` |';
        $lines[] = '| `size` | `tokens.size.icon.{n}` | CSS length | `"16px"`, `"24px"` |';
        $lines[] = '| `size` | `tokens.size.hitTargetMin` | CSS length | `"44px"` |';
        $lines[] = '| `radius` | `tokens.radius.scale.{key}` | CSS length or `"9999px"` | `"4px"`, `"9999px"` |';
        $lines[] = '| `radius` | `tokens.radius.aliases.{name}` | Scale key reference | `"sm"`, `"pill"` |';
        $lines[] = '| `border` | `tokens.border.{name}` | CSS length | `"1px"`, `"2px"` |';
        $lines[] = '| `typography` | `tokens.typography.families.{name}` | CSS font-family string | `"Inter, sans-serif"` |';
        $lines[] = '| `typography` | `tokens.typography.weights.{name}` | Numeric weight | `"400"`, `"600"`, `"700"` |';
        $lines[] = '| `typography` | `tokens.typography.sizes.{name}` | CSS length | `"14px"`, `"1rem"` |';
        $lines[] = '| `typography` | `tokens.typography.lineHeights.{name}` | Unitless ratio or CSS | `"1.5"`, `"1.2"` |';
        $lines[] = '| `typography` | `tokens.typography.tracking.{name}` | CSS letter-spacing | `"0"`, `"0.02em"` |';
        $lines[] = '| `color.semantic` | `tokens.color.semantic.{name}` | Mode-keyed object (§4) OR single hex/hsl/oklch | `{"light":"#fff","dark":"#111"}` |';
        $lines[] = '| `color.derived` | `tokens.color.derived.{name}` | CSS color value | `"rgba(0,0,0,0.08)"` |';
        $lines[] = '| `shadow` | `tokens.shadow.{name}` | CSS box-shadow value | `"none"`, `"0 1px 2px rgba(0,0,0,0.08)"` |';
        $lines[] = '| `motion.duration` | `tokens.motion.duration.{name}` | CSS time in `ms` | `"0ms"`, `"180ms"` |';
        $lines[] = '| `motion.easing` | `tokens.motion.easing.{name}` | CSS easing | `"cubic-bezier(0.2,0,0,1)"` |';
        $lines[] = '| `layout.containers` | `tokens.layout.containers.{name}` | CSS length | `"960px"`, `"1120px"` |';
        $lines[] = '| `layout.gutters` | `tokens.layout.gutters.{name}` | CSS length | `"16px"`, `"24px"` |';
        $lines[] = '| `focus` | `tokens.focus.{name}` | CSS length | `"2px"` |';
        $lines[] = '| `zIndex` | `tokens.zIndex.{name}` | Integer (as number) | `0`, `10`, `100` |';
        $lines[] = '';
        $lines[] = '---';
        $lines[] = '';
        $lines[] = '## 4. Appearance Mode Structure for Semantic Colors';
        $lines[] = '';
        $lines[] = 'Semantic color tokens support light/dark mode. The value must be an object keyed by the modes declared in `settings.appearanceModes`.';
        $lines[] = '';
        $lines[] = '**Single-mode install (light only):**';
        $lines[] = '```json';
        $lines[] = '"tokens": {';
        $lines[] = '  "color": {';
        $lines[] = '    "semantic": {';
        $lines[] = '      "bg":    "#ffffff",';
        $lines[] = '      "text":  "#111111",';
        $lines[] = '      "brand": "#0066ff"';
        $lines[] = '    }';
        $lines[] = '  }';
        $lines[] = '}';
        $lines[] = '```';
        $lines[] = '';
        $lines[] = '**Dual-mode install (light + dark):**';
        $lines[] = '```json';
        $lines[] = '"tokens": {';
        $lines[] = '  "color": {';
        $lines[] = '    "semantic": {';
        $lines[] = '      "bg":    { "light": "#ffffff", "dark": "#111111" },';
        $lines[] = '      "text":  { "light": "#111111", "dark": "#f0f0f0" },';
        $lines[] = '      "brand": { "light": "#0066ff", "dark": "#3d8bff" }';
        $lines[] = '    }';
        $lines[] = '  }';
        $lines[] = '}';
        $lines[] = '```';
        $lines[] = '';
        $lines[] = 'The validator checks WCAG contrast ratios between `bg` and `text` semantic tokens per the `wcagPolicy` setting. AA requires ≥4.5:1 for normal text.';
        $lines[] = '';
        $lines[] = '---';
        $lines[] = '';
        $lines[] = '## 5. Alias Token References';
        $lines[] = '';
        $lines[] = 'Alias tokens (`space.aliases`, `radius.aliases`) reference scale keys by name, not by CSS value.';
        $lines[] = '';
        $lines[] = '```json';
        $lines[] = '"space": {';
        $lines[] = '  "scale": { "s0": "0px", "s1": "8px", "s2": "16px", "s3": "24px" },';
        $lines[] = '  "aliases": { "none": "s0", "xs": "s0_5", "sm": "s1", "md": "s2", "lg": "s3" }';
        $lines[] = '}';
        $lines[] = '```';
        $lines[] = '';
        $lines[] = 'Alias values must be valid keys in the corresponding scale object. The validator enforces this.';
        $lines[] = '';
        $lines[] = '---';
        $lines[] = '';
        $lines[] = '## 6. fontRoles Structure';
        $lines[] = '';
        $lines[] = '```json';
        $lines[] = '"fontRoles": {';
        $lines[] = '  "googleFontsCss2Url": "https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap",';
        $lines[] = '  "roles": {';
        $lines[] = '    "headlines":  { "family": "Inter, sans-serif", "fallback": "sans-serif", "weight": "700", "style": "normal", "size": "3rem" },';
        $lines[] = '    "subtitles":  { "family": "Inter, sans-serif", "fallback": "sans-serif", "weight": "600", "style": "normal", "size": "1.25rem" },';
        $lines[] = '    "body":       { "family": "Inter, sans-serif", "fallback": "sans-serif", "weight": "400", "style": "normal", "size": "1rem" },';
        $lines[] = '    "ui":         { "family": "Inter, sans-serif", "fallback": "sans-serif", "weight": "500", "style": "normal", "size": "0.875rem" },';
        $lines[] = '    "micro":      { "family": "Inter, sans-serif", "fallback": "sans-serif", "weight": "400", "style": "normal", "size": "0.75rem" },';
        $lines[] = '    "eyebrow":    { "family": "JetBrains Mono", "fallback": "monospace", "weight": "500", "style": "normal", "size": "0.75rem" }';
        $lines[] = '  }';
        $lines[] = '}';
        $lines[] = '```';
        $lines[] = '';
        $lines[] = '- `googleFontsCss2Url`: a Google Fonts CSS2 API URL, or empty string if using system fonts';
        $lines[] = '- `roles`: required keys are `headlines`, `subtitles`, `body`, `ui`, `micro`; optional `eyebrow` uses the same properties and falls back property-by-property to `micro` when absent';
        $lines[] = '';
        $lines[] = '---';
        $lines[] = '';
        $lines[] = '## 7. Validation Rules — What the Validator Rejects';
        $lines[] = '';
        $lines[] = '- `schemaVersion` ≠ `"1"`';
        $lines[] = '- `format` ≠ `"JSONM"`';
        $lines[] = '- `kind` not in `["baseline", "named", "preset"]`';
        $lines[] = '- `id` or `name` missing or empty';
        $lines[] = '- `corpus.id` ≠ `"activetwist-design-corpus/v1"`';
        $lines[] = '- `corpus.sourceFiles` empty or missing';
        $lines[] = '- `settings.wcagPolicy` not `"AA"` or `"AAA"`';
        $lines[] = '- `settings.appearanceModes` empty, or contains values other than `"light"` / `"dark"`';
        $lines[] = '- `settings.defaultMode` not in `settings.appearanceModes`';
        $lines[] = '- Any token path in the install\'s token map missing from the document';
        $lines[] = '- Semantic color tokens not matching the declared appearance modes';
        $lines[] = '- WCAG contrast between `bg` and `text` failing the declared `wcagPolicy`';
        $lines[] = '- Alias tokens referencing keys that do not exist in the corresponding scale';
        $lines[] = '';
        $lines[] = '---';
        $lines[] = '';
        $lines[] = '## 8. Live Example — Current Install Definition';
        $lines[] = '';
        $lines[] = 'The following is the actual JSONM exported from this install. It is valid by definition and is the best starting point for translation or modification.';
        $lines[] = '';
        $lines[] = '```json';
        $lines[] = $exampleJson;
        $lines[] = '```';
        $lines[] = '';
        $lines[] = '---';
        $lines[] = '';
        $lines[] = '## 9. Translation Hints for External AI Design Tools';
        $lines[] = '';
        $lines[] = '### Figma Variables → JSONM';
        $lines[] = '';
        $lines[] = 'Figma organizes design tokens into **variable collections** with modes. The mapping strategy:';
        $lines[] = '';
        $lines[] = '1. **Color variables** (Figma COLOR type) → `tokens.color.semantic.{name}` or `tokens.color.derived.{name}`';
        $lines[] = '   - Single-mode: use the flat hex/hsl value directly';
        $lines[] = '   - Multi-mode collection: map Figma mode names to `light`/`dark` keys in the JSONM mode-keyed object';
        $lines[] = '2. **Number variables** (Figma FLOAT type) with px units → space, size, radius, border tokens';
        $lines[] = '   - Map to the nearest scale key in `tokens.space.scale` or create a new named scale entry';
        $lines[] = '3. **String variables** → font family tokens in `tokens.typography.families`';
        $lines[] = '4. **Figma variable name → CSS var pattern**: Figma `Space/MD` → `--myron-space-md` → `tokens.space.aliases.md`';
        $lines[] = '5. Export all Figma variables as JSON, then map each variable path segment to the corresponding JSONM dot-path';
        $lines[] = '';
        $lines[] = '**Key constraint**: every token path required by this install must be present. Do not omit tokens that exist in the export (§8) even if Figma has no equivalent — carry forward the original value.';
        $lines[] = '';
        $lines[] = '### W3C DTCG Format → JSONM';
        $lines[] = '';
        $lines[] = 'The W3C Design Token Community Group format uses `$type` and `$value` at each leaf. Mapping rules:';
        $lines[] = '';
        $lines[] = '| DTCG `$type` | JSONM family | Notes |';
        $lines[] = '|---|---|---|';
        $lines[] = '| `color` | `tokens.color.semantic` or `tokens.color.derived` | Use semantic for brand/UI colors, derived for computed values |';
        $lines[] = '| `dimension` | `tokens.space`, `tokens.size`, `tokens.radius`, `tokens.border`, `tokens.layout` | Map by semantic meaning of the token name |';
        $lines[] = '| `fontFamily` | `tokens.typography.families` and `fontRoles.roles.{role}.family` | |';
        $lines[] = '| `fontWeight` | `tokens.typography.weights` | Numeric string: `"400"`, `"700"` |';
        $lines[] = '| `fontSize` | `tokens.typography.sizes` | CSS length value |';
        $lines[] = '| `lineHeight` | `tokens.typography.lineHeights` | Unitless or CSS |';
        $lines[] = '| `letterSpacing` | `tokens.typography.tracking` | CSS letter-spacing |';
        $lines[] = '| `duration` | `tokens.motion.duration` | Value must include `ms` unit |';
        $lines[] = '| `cubicBezier` | `tokens.motion.easing` | Serialize as `"cubic-bezier(a,b,c,d)"` string |';
        $lines[] = '| `shadow` | `tokens.shadow` | Serialize as CSS `box-shadow` string |';
        $lines[] = '| `number` (z-index) | `tokens.zIndex` | Integer value |';
        $lines[] = '';
        $lines[] = '**Round-trip strategy**: export the current JSONM via `GET /api/v1/design/definitions/export-current`, translate to your tool\'s format, edit in the tool, translate back using this guide, then import via `POST /api/v1/design/definitions/import` with the JSONM base64-encoded in the `contentBase64` field.';
        $lines[] = '';

        return implode("\n", $lines) . "\n";
    }

    /**
     * @return array<string, mixed>
     */
    private function composeManifestPayload(MyronActionCallerContext $caller): array
    {
        $entries = [];
        foreach ($this->registry->all() as $action) {
            $meta = $action->metadata();
            if (!$meta->exposeToMcp) {
                continue;
            }
            if ($this->isMcpHiddenByPolicy($meta)) {
                continue;
            }
            if (!$this->actionAllowedByEntitlement($meta)) {
                continue;
            }
            if ($meta->requiredScope !== null && !$caller->holdsScope($meta->requiredScope)) {
                continue;
            }
            $entries[] = $meta->toArray();
        }
        usort($entries, static fn (array $a, array $b): int => strcmp((string) $a['uri'], (string) $b['uri']));
        return [
            'version'     => '1',
            'generatedAt' => date('Y-m-d\TH:i:s\Z'),
            'actions'     => $entries,
        ];
    }

    private function resolveSiteTypeFromUri(string $uri): string
    {
        $query = parse_url($uri, PHP_URL_QUERY) ?? '';
        parse_str($query, $params);
        return (string) ($params['siteType'] ?? 'BIZ');
    }

    public function findActionByToolName(string $toolName): ?MyronAction
    {
        foreach ($this->registry->all() as $action) {
            $meta = $action->metadata();
            if (self::toolNameFor($meta) === $toolName || self::legacyToolNameFor($meta) === $toolName) {
                return $action;
            }
        }
        return null;
    }

    /**
     * Translate action metadata to a strict-client-safe MCP tool name.
     */
    public static function toolNameFor(MyronActionMetadata $meta): string
    {
        if ($meta->domain === 'analytics' && $meta->verb === 'get') {
            return match ($meta->resource) {
                'summary' => 'get_analytics_summary',
                'top-pages' => 'get_top_pages',
                'traffic-sources' => 'get_traffic_sources',
                default => '',
            } ?: self::defaultToolNameFor($meta);
        }
        if ($meta->domain === 'content' && str_starts_with($meta->resource, 'quiz')) {
            return match ($meta->resource . ':' . $meta->verb) {
                'quiz:create' => 'quiz_create',
                'quiz:update' => 'quiz_update',
                'quiz:set-presentation' => 'quiz_set_presentation',
                'quiz:publish' => 'quiz_publish',
                'quiz:archive' => 'quiz_archive',
                'quiz:duplicate' => 'quiz_duplicate',
                'quiz:list' => 'quiz_list',
                'quiz:get' => 'quiz_get',
                'quiz-item:add' => 'quiz_add_item',
                'quiz-result:add' => 'quiz_add_result',
                'quiz-automation-rule:add' => 'quiz_add_automation_rule',
                'quiz-analytics:get' => 'quiz_get_analytics',
                default => self::defaultToolNameFor($meta),
            };
        }
        return self::defaultToolNameFor($meta);
    }

    private static function defaultToolNameFor(MyronActionMetadata $meta): string
    {
        $base = $meta->domain . '_' . $meta->resource . '_' . $meta->verb;
        $normalized = preg_replace('/[^A-Za-z0-9_]+/', '_', $base) ?? $base;
        $normalized = preg_replace('/_+/', '_', $normalized) ?? $normalized;
        return trim(strtolower($normalized), '_');
    }

    /**
     * Legacy transitional lookup for direct probes created before v0.13.3.
     */
    public static function legacyToolNameFor(MyronActionMetadata $meta): string
    {
        return $meta->domain . '.' . $meta->resource . '.' . $meta->verb;
    }

    /**
     * @param array<string, string|null> $headers
     */
    private function requireBearerCaller(array $headers): MyronActionCallerContext
    {
        if (!$this->hasBearerHeader($headers)) {
            throw new MyronMcpProtocolError(-32000, 'Authorization required: missing Authorization: Bearer <token> header.', ['code' => 'MCP_AUTH_FAILED']);
        }
        try {
            return $this->auth->resolveCaller($headers, false);
        } catch (MyronApiAuthError $e) {
            throw new MyronMcpProtocolError(-32000, $e->getMessage(), ['code' => 'MCP_AUTH_FAILED', 'errorCode' => $e->errorCode]);
        } catch (Throwable $e) {
            throw new MyronMcpProtocolError(-32000, $e->getMessage(), ['code' => 'MCP_AUTH_FAILED']);
        }
    }

    /**
     * @param array<string, string|null> $headers
     */
    private function requireInitializedCaller(array $headers): MyronActionCallerContext
    {
        $caller = $this->requireBearerCaller($headers);
        $sessionId = $this->extractSessionId($headers);
        if ($sessionId === '') {
            throw new MyronMcpProtocolError(-32000, 'MCP session required. Call initialize and send the returned Mcp-Session-Id header.', ['code' => 'MCP_SESSION_REQUIRED']);
        }
        try {
            $record = $this->sessionStore()->requireValid($sessionId, $caller);
        } catch (Throwable $e) {
            throw new MyronMcpProtocolError(-32000, $e->getMessage(), ['code' => 'MCP_SESSION_INVALID']);
        }
        if (($record['initialized'] ?? false) !== true) {
            throw new MyronMcpProtocolError(-32000, 'MCP initialized notification required before discovery or tool calls.', ['code' => 'MCP_SESSION_NOT_INITIALIZED']);
        }
        $this->assertEntitled('mcp.site_operations');
        return $caller;
    }

    /**
     * Operational MCP methods are authorized by durable bearer/OAuth tokens.
     * Session headers are intentionally optional here so clean deploys, idle
     * gaps, or non-shared session stores cannot break an otherwise valid caller.
     *
     * @param array<string, string|null> $headers
     */
    private function requireOperationalCaller(array $headers): MyronActionCallerContext
    {
        $caller = $this->requireBearerCaller($headers);
        $this->assertEntitled('mcp.site_operations');
        return $caller;
    }

    private function callerCanReadResource(MyronActionCallerContext $caller, array $resource): bool
    {
        $scope = $resource['requiredScope'] ?? null;
        return !is_string($scope) || $scope === '' || $caller->holdsScope($scope);
    }

    private function assertResourceReadable(MyronActionCallerContext $caller, string $uri): void
    {
        $resources = $this->resourcesForCaller($caller);
        foreach ($resources as $resource) {
            $listedUri = (string) ($resource['uri'] ?? '');
            if ($listedUri === $uri) {
                return;
            }
            if ($listedUri === 'mcp://myroncms/system/compatibility/default'
                && str_starts_with($uri, 'mcp://myroncms/system/compatibility/default?')
            ) {
                return;
            }
        }
        throw new MyronMcpProtocolError(-32602, 'Resource not found or not permitted: ' . $uri, ['code' => 'MCP_RESOURCE_NOT_FOUND']);
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function resourcesForCaller(MyronActionCallerContext $caller): array
    {
        return array_values(array_map(
            static function (array $resource): array {
                unset($resource['requiredScope']);
                return $resource;
            },
            array_filter($this->resourceCatalog(), fn (array $resource): bool => $this->callerCanReadResource($caller, $resource))
        ));
    }

    /**
     * @return list<array<string, mixed>>
     */
    private function resourceCatalog(): array
    {
        return [
            [
                'uri' => 'mcp://myroncms/system/manifest',
                'name' => 'Action Manifest',
                'description' => 'Scope-filtered action catalog for this MCP caller.',
                'mimeType' => 'application/json',
                'requiredScope' => null,
            ],
            [
                'uri' => 'mcp://myroncms/system/snapshot',
                'name' => 'System Snapshot',
                'description' => 'Live install state composed from all snapshotInclude:true read actions.',
                'mimeType' => 'application/json',
                'requiredScope' => MyronApiScopes::ADMINISTRATION_READ,
            ],
            [
                'uri' => 'mcp://myroncms/system/compatibility/configured',
                'name' => 'Compatibility Document (configured)',
                'description' => 'Live state of the running install: active site type, bundle, default footer, supported actions.',
                'mimeType' => 'application/json',
                'requiredScope' => MyronApiScopes::ADMINISTRATION_READ,
            ],
            [
                'uri' => 'mcp://myroncms/system/compatibility/default',
                'name' => 'Compatibility Document (default)',
                'description' => 'Per-site-type default: what activates out-of-the-box for a fresh install.',
                'mimeType' => 'application/json',
                'requiredScope' => null,
            ],
            [
                'uri' => 'mcp://creative-component-sdk',
                'name' => 'Creative Component SDK',
                'description' => 'Agent-authoring guide for building MyronCMS-compatible creative components, with live install context: motion profiles, scene backgrounds, and declared external dependencies.',
                'mimeType' => 'text/markdown',
                'requiredScope' => MyronApiScopes::DESIGN_READ,
            ],
            [
                'uri' => 'mcp://myroncms/design/jsonm-interchange',
                'name' => 'JSONM Interchange Spec',
                'description' => 'Self-contained JSONM design system authoring specification. Covers document structure, token path conventions, valid value formats, appearance mode shape, alias references, fontRoles, validation rules, a live example from this install, and translation hints for Figma variables and W3C DTCG format. Read this before producing a JSONM document for import.',
                'mimeType' => 'text/markdown',
                'requiredScope' => MyronApiScopes::DESIGN_READ,
            ],
            [
                'uri' => 'mcp://myroncms/content/blog-post-authoring',
                'name' => 'MCP Blog Post Authoring Workflow',
                'description' => 'End-to-end MCP workflow for creating, categorizing, authoring, and publishing blog posts with Markdown body support.',
                'mimeType' => 'text/markdown',
                'requiredScope' => MyronApiScopes::CONTENT_READ,
            ],
        ];
    }

    private function scopeFilterCompatibilityDocument(array $document, MyronActionCallerContext $caller): array
    {
        if (isset($document['manifest']) && is_array($document['manifest'])) {
            $document['manifest'] = $this->filterManifestSummary($document['manifest'], $caller);
        }
        if (isset($document['snapshot']) && is_array($document['snapshot'])) {
            $document['snapshot'] = $this->scopeFilterSnapshot($document['snapshot'], $caller);
        }
        return $document;
    }

    private function scopeFilterSnapshot(array $snapshot, MyronActionCallerContext $caller): array
    {
        if (isset($snapshot['manifest']) && is_array($snapshot['manifest'])) {
            $snapshot['manifest'] = $this->filterManifestSummary($snapshot['manifest'], $caller);
        }
        return $snapshot;
    }

    private function filterManifestSummary(array $manifest, MyronActionCallerContext $caller): array
    {
        $entries = [];
        foreach ((array) ($manifest['entries'] ?? []) as $entry) {
            if (!is_array($entry)) {
                continue;
            }
            if (($entry['exposeToMcp'] ?? false) !== true) {
                continue;
            }
            if ($this->isMcpHiddenEntryByPolicy($entry)) {
                continue;
            }
            if (!$this->entryAllowedByEntitlement($entry)) {
                continue;
            }
            $scope = $entry['requiredScope'] ?? null;
            if (is_string($scope) && $scope !== '' && !$caller->holdsScope($scope)) {
                continue;
            }
            $entries[] = $entry;
        }
        $manifest['entries'] = $entries;
        $manifest['totalActions'] = count($entries);
        $manifest['mcpExposedActions'] = count($entries);
        return $manifest;
    }

    private function isMcpHiddenByPolicy(MyronActionMetadata $meta): bool
    {
        if ($meta->domain === 'administration' && str_starts_with($meta->resource, 'ai-connection')) {
            return true;
        }
        if ($meta->domain === 'system' && $meta->resource === 'audit-log') {
            return true;
        }
        if ($meta->domain === 'administration' && str_starts_with($meta->resource, 'user')) {
            return true;
        }
        return false;
    }

    private function actionAllowedByEntitlement(MyronActionMetadata $meta): bool
    {
        $capability = $this->capabilityForActionMetadata($meta);
        return $capability === '' || $this->entitlementAllows($capability);
    }

    /**
     * @param array<string, mixed> $entry
     */
    private function entryAllowedByEntitlement(array $entry): bool
    {
        $capability = $this->capabilityForActionEntry($entry);
        return $capability === '' || $this->entitlementAllows($capability);
    }

    private function assertEntitled(string $capability): void
    {
        if (!$this->entitlementAllows($capability)) {
            throw new MyronMcpProtocolError(-32000, 'MCP is not available for the current license.', ['code' => 'MCP_ENTITLEMENT_DENIED']);
        }
    }

    private function entitlementAllows(string $capability): bool
    {
        if (!function_exists('myronEntitlementGate')) {
            return false;
        }
        try {
            $decision = myronEntitlementGate()->evaluate($capability);
        } catch (Throwable) {
            return false;
        }

        return in_array((string) ($decision['outcome'] ?? ''), ['allowed', 'warn'], true);
    }

    private function capabilityForActionMetadata(MyronActionMetadata $meta): string
    {
        if ($meta->requiredCapability !== '') {
            return $meta->requiredCapability;
        }

        return $this->capabilityForActionParts($meta->domain, $meta->resource, $meta->verb);
    }

    /**
     * @param array<string, mixed> $entry
     */
    private function capabilityForActionEntry(array $entry): string
    {
        $requiredCapability = trim((string) ($entry['requiredCapability'] ?? ''));
        if ($requiredCapability !== '') {
            return $requiredCapability;
        }

        return $this->capabilityForActionParts(
            (string) ($entry['domain'] ?? ''),
            (string) ($entry['resource'] ?? ''),
            (string) ($entry['verb'] ?? '')
        );
    }

    private function capabilityForActionParts(string $domain, string $resource, string $verb): string
    {
        $normalized = strtolower($domain . ' ' . $resource . ' ' . $verb);
        if (str_contains($normalized, 'byok') || str_contains($normalized, 'llm-provider') || str_contains($normalized, 'ai-provider')) {
            return 'mcp.byok_ai_actions';
        }
        if ($domain === 'analytics') {
            return 'analytics.native';
        }
        if ($domain === 'administration' && str_starts_with($resource, 'mail')) {
            return 'mail.multiple_configs';
        }
        if ($domain === 'administration.mailTransports' || $resource === 'mail-transport') {
            return 'mail.multiple_configs';
        }
        if ($domain === 'administration' && str_starts_with($resource, 'application-update')) {
            return 'updates.security';
        }
        if ($domain === 'administration' && $resource === 'search-index') {
            return 'search.public';
        }
        if ($domain === 'content' && $resource === 'search') {
            return 'search.public';
        }
        if ($domain === 'design' && $resource === 'document-template') {
            return 'templates.document';
        }
        if ($domain === 'design' && $resource === 'section-variant') {
            return 'templates.section_variants';
        }
        if ($domain === 'design' && $resource === 'section-template') {
            return 'templates.custom_section';
        }
        if (str_starts_with($domain, 'devops') || str_contains($normalized, 'environment-pair') || str_contains($normalized, 'deployment-token')) {
            return 'devops.environments';
        }

        return '';
    }

    /**
     * @param array<string, mixed> $entry
     */
    private function isMcpHiddenEntryByPolicy(array $entry): bool
    {
        $domain = (string) ($entry['domain'] ?? '');
        $resource = (string) ($entry['resource'] ?? '');
        if ($domain === 'administration' && str_starts_with($resource, 'ai-connection')) {
            return true;
        }
        if ($domain === 'system' && $resource === 'audit-log') {
            return true;
        }
        if ($domain === 'administration' && str_starts_with($resource, 'user')) {
            return true;
        }
        return false;
    }

    /**
     * @return array<string, string>
     */
    public function consumeResponseHeaders(): array
    {
        $headers = $this->responseHeaders;
        $this->responseHeaders = [];
        return $headers;
    }

    private function sessionStore(): MyronMcpSessionStore
    {
        return $this->sessions ?? MyronMcpSessionStore::default();
    }

    /**
     * @param array<string, string|null> $headers
     */
    private function hasBearerHeader(array $headers): bool
    {
        $value = $this->headerValue($headers, 'Authorization');
        if ($value === '') {
            $value = $this->headerValue($headers, 'HTTP_AUTHORIZATION');
        }
        if ($value === '') {
            $value = $this->headerValue($headers, 'REDIRECT_HTTP_AUTHORIZATION');
        }
        return stripos(trim($value), 'bearer ') === 0;
    }

    /**
     * @param array<string, string|null> $headers
     */
    private function extractSessionId(array $headers): string
    {
        return trim($this->headerValue($headers, self::SESSION_HEADER));
    }

    /**
     * @param array<string, string|null> $headers
     */
    private function headerValue(array $headers, string $target): string
    {
        foreach ($headers as $name => $value) {
            if (strcasecmp((string) $name, $target) === 0) {
                return (string) $value;
            }
        }
        return '';
    }

    /**
     * @return object
     */
    private static function emptyResult(): object
    {
        return (object) [];
    }

    /**
     * @return array<string, mixed>
     */
    public static function successResponse(mixed $id, mixed $result): array
    {
        return [
            'jsonrpc' => '2.0',
            'id'      => $id,
            'result'  => $result,
        ];
    }

    /**
     * @return array<string, mixed>
     */
    public static function errorResponse(mixed $id, int $code, string $message, ?array $data = null): array
    {
        $error = ['code' => $code, 'message' => $message];
        if ($data !== null) {
            $error['data'] = $data;
        }
        return [
            'jsonrpc' => '2.0',
            'id'      => $id,
            'error'   => $error,
        ];
    }
}

/**
 * Thrown when an unsupported MCP method is requested.
 */
final class MyronMcpMethodNotFound extends RuntimeException
{
}

/**
 * Structured protocol error carrying the JSON-RPC error code + optional data.
 */
final class MyronMcpProtocolError extends RuntimeException
{
    /**
     * @param array<string, mixed>|null $data
     */
    public function __construct(
        public readonly int $jsonRpcCode,
        string $message,
        public readonly ?array $data = null,
    ) {
        parent::__construct($message);
    }
}
