_...`, executors `.`, admin sections `` or `-...`. */ final class ModuleContext { /** Longest adapter hash prefix: "_" + 64 hex must fit the journal's char(68) hash columns. */ public const MAX_PREFIX = 3; /** Longest guide a module adds to the MCP instructions, and the longest category description (characters). */ public const MAX_INSTRUCTIONS = 1200; public const MAX_CATEGORY_DESCRIPTION = 200; /** Core categories a module may join, and its tools per category. */ public const MAX_RELATED_CATEGORIES = 4; public const MAX_RELATED_TOOLS = 12; private bool $sealed = false; private ?string $error = null; /** @var array */ private array $adapters = []; /** @var array */ private array $executors = []; /** @var array,handler:?callable}> */ private array $tools = []; /** @var array */ private array $sections = []; /** @var array{instructions:string,category:string,related:array>,elementor:bool} */ private array $agents = ['instructions' => '', 'category' => '', 'related' => [], 'elementor' => false]; /** @internal Created by ModuleRegistry only. */ public function __construct(private string $id, private string $namespace, private string $root) { } public function moduleId(): string { return $this->id; } /** * A reversible domain. Its type must be `_...` (up to 48 characters) and its hash prefix 1 to 3 lowercase letters * or digits: the revision hash ("_" + 64 hex) must fit the journal's 68-character hash columns. */ public function registerAdapter(Adapter $adapter): bool { if (!$this->open()) { return false; } $type = $adapter->type(); if (!preg_match('/^[a-z][a-z0-9_]{1,47}$/', $type) || strpos($type, $this->id . '_') !== 0) { return $this->refuse('adapter_type_invalid', $type); } if (isset($this->adapters[$type])) { return $this->refuse('adapter_duplicate', $type); } if (!preg_match('/^[a-z0-9]{1,' . self::MAX_PREFIX . '}$/', $adapter->prefix())) { return $this->refuse('adapter_prefix_invalid', $type . ' (the hash prefix must be 1 to ' . self::MAX_PREFIX . ' lowercase letters or digits, so "_" + 64 hex fits the 68-character journal hash column)'); } if (!$this->ownsClass(get_class($adapter))) { return $this->refuse('adapter_not_owned', get_class($adapter)); } $this->adapters[$type] = $adapter; return true; } /** * A write executor, `.`. It is called as fn(array $arguments, array $auth, string $tool, WriteGateway $gateway) * and writes only through $gateway, which runs the Engine. */ public function registerExecutor(string $name, callable $executor): bool { if (!$this->open()) { return false; } if (!preg_match('/^[a-z][a-z0-9]*\.[a-z][a-z0-9_]*$/', $name) || strpos($name, $this->id . '.') !== 0) { return $this->refuse('executor_name_invalid', $name); } if (isset($this->executors[$name])) { return $this->refuse('executor_duplicate', $name); } if (!$this->ownsCallable($executor)) { return $this->refuse('executor_not_owned', $name); } $this->executors[$name] = $executor; return true; } /** * A read tool with its query handler. * * @param array> $properties * @param list $required */ public function registerReadTool(string $name, string $description, string $capability, array $properties, array $required, callable $handler): bool { if (!$this->open()) { return false; } if (!$this->validToolName($name)) { return false; } if (!$this->ownsCallable($handler)) { return $this->refuse('handler_not_owned', $name); } $this->tools[$name] = [ 'definition' => [ 'name' => $name, 'description' => $description, 'category' => $this->id, 'access' => 'read', 'capability' => $capability, 'inputSchema' => Toolkit::schema($properties, $required), 'annotations' => Toolkit::annotations($name, 'read'), 'module' => $this->id, ], 'handler' => $handler, ]; return true; } /** * A write tool. It never takes a callable: it names one of this module's executors, and the write envelope * (dry_run, idempotency_key, confirm and, for existing items, expected_hash) is added here like in the core. * * @param array> $properties * @param list $required */ public function registerWriteTool(string $name, string $description, string $capability, array $properties, array $required, bool $existing, string $executor): bool { if (!$this->open()) { return false; } if (!$this->validToolName($name)) { return false; } if (!isset($this->executors[$executor])) { return $this->refuse('executor_unknown', $name); } $this->tools[$name] = [ 'definition' => [ 'name' => $name, 'description' => $description, 'category' => $this->id, 'access' => 'write', 'capability' => $capability, 'inputSchema' => Toolkit::schema($properties + Toolkit::writeProps($existing), array_values(array_unique(array_merge($required, Toolkit::writeRequired($existing))))), 'executor' => $executor, 'annotations' => Toolkit::annotations($name, 'write'), 'module' => $this->id, ], 'handler' => null, ]; return true; } /** A section of the Llamly admin page, `` or `-...`. The HTML goes through wp_kses_post like any section. */ public function registerAdminSection(string $id, string $label, callable $renderer, string $capability = 'manage_options'): bool { if (!$this->open()) { return false; } $label = sanitize_text_field($label); if (($id !== $this->id && strpos($id, $this->id . '-') !== 0) || strlen($id) > 64 || !preg_match('/^[a-z][a-z0-9_-]*$/', $id) || $label === '' || strlen($label) > 80 || sanitize_key($capability) === '' || isset($this->sections[$id]) ) { return $this->refuse('section_invalid', $id); } if (!$this->ownsCallable($renderer)) { return $this->refuse('section_not_owned', $id); } $this->sections[$id] = ['label' => $label, 'renderer' => $renderer, 'capability' => sanitize_key($capability)]; return true; } /** * What a connected agent reads about the module (all optional; a module that never calls it changes nothing). * $instructions joins the MCP instructions while the module is active and the connection can use at least one of its * tools: plain text, up to MAX_INSTRUCTIONS characters, line breaks allowed. $category describes the module's own * category (named after the module id) in llamly_discover: plain text, one line, up to MAX_CATEGORY_DESCRIPTION. */ public function describeForAgents(string $instructions, string $category = ''): bool { if (!$this->open()) { return false; } $instructions = trim(str_replace("\r\n", "\n", $instructions)); $category = trim($category); if (!self::plainText($instructions, self::MAX_INSTRUCTIONS, true)) { return $this->refuse('instructions_invalid', 'plain text without markup or control characters, up to ' . self::MAX_INSTRUCTIONS . ' characters'); } if (!self::plainText($category, self::MAX_CATEGORY_DESCRIPTION, false)) { return $this->refuse('category_description_invalid', 'one line of plain text, up to ' . self::MAX_CATEGORY_DESCRIPTION . ' characters'); } $this->agents['instructions'] = $instructions; $this->agents['category'] = $category; return true; } /** * Show some of the module's tools also when an agent browses a core category in llamly_discover (for example * "gutenberg"), before the core tools of that category. The tools must be registered by this module (checked when * register() returns); a category that does not exist in Llamly is ignored. * * @param list $tools */ public function addToCategory(string $category, array $tools): bool { if (!$this->open()) { return false; } if (!preg_match('/^[a-z][a-z0-9_]{1,31}$/', $category) || $category === $this->id || isset($this->agents['related'][$category])) { return $this->refuse('category_invalid', $category); } if (count($this->agents['related']) >= self::MAX_RELATED_CATEGORIES) { return $this->refuse('category_limit', $category . ' (up to ' . self::MAX_RELATED_CATEGORIES . ' categories)'); } $tools = array_values(array_unique(array_map('strval', $tools))); if (!$tools || count($tools) > self::MAX_RELATED_TOOLS) { return $this->refuse('category_tools_invalid', $category . ' (1 to ' . self::MAX_RELATED_TOOLS . ' tools)'); } foreach ($tools as $tool) { if (strpos($tool, $this->id . '_') !== 0) { return $this->refuse('category_tool_unknown', $tool); } } $this->agents['related'][$category] = $tools; return true; } /** The module's write tools change Elementor documents, so the instructions stop calling Elementor read-only. */ public function declareElementorWriter(): bool { if (!$this->open()) { return false; } $this->agents['elementor'] = true; return true; } /** @internal */ public function seal(): void { if (!$this->sealed && $this->error === null) { // Tools named by addToCategory() may be registered after it, so they are checked once register() returned. foreach ($this->agents['related'] as $tools) { foreach ($tools as $tool) { if (!isset($this->tools[$tool])) { $this->refuse('category_tool_unknown', $tool); break 2; } } } } $this->sealed = true; } /** @internal */ public function fail(string $code, string $detail = ''): void { $this->refuse($code, $detail); } /** @internal Null when everything was accepted, otherwise "code: detail" of the first refusal. */ public function error(): ?string { return $this->error; } /** @internal @return array{adapters:array,executors:array,tools:array,handler:?callable}>,sections:array,agents:array{instructions:string,category:string,related:array>,elementor:bool}} */ public function staged(): array { return ['adapters' => $this->adapters, 'executors' => $this->executors, 'tools' => $this->tools, 'sections' => $this->sections, 'agents' => $this->agents]; } private function open(): bool { if ($this->sealed) { // Registering after register() returned: the module was already accepted or refused as a whole. return false; } return $this->error === null; } private function refuse(string $code, string $detail): bool { if ($this->error === null) { $this->error = $code . ($detail !== '' ? ': ' . $detail : ''); } return false; } /** Plain text: valid UTF-8, no markup, no control characters (line breaks only when $multiline). */ private static function plainText(string $text, int $max, bool $multiline): bool { if ($text === '') { return true; } $length = function_exists('mb_strlen') ? mb_strlen($text, 'UTF-8') : strlen($text); if ($length > $max || !wp_check_invalid_utf8($text) || wp_strip_all_tags($text) !== $text || strpos($text, '<') !== false) { return false; } return !preg_match($multiline ? '/[\x00-\x09\x0b-\x1f\x7f]/' : '/[\x00-\x1f\x7f]/', $text); } private function validToolName(string $name): bool { if (!preg_match('/^[a-z][a-z0-9_]{1,63}$/', $name) || strpos($name, $this->id . '_') !== 0) { return $this->refuse('tool_name_invalid', $name); } if (isset($this->tools[$name])) { return $this->refuse('tool_duplicate', $name); } return true; } /** Class in the module namespace whose file is inside the module root. */ private function ownsClass(string $class): bool { if (strpos($class, $this->namespace) !== 0) { return false; } try { $file = (new \ReflectionClass($class))->getFileName(); } catch (\ReflectionException $error) { return false; } return is_string($file) && ModuleRegistry::inside($file, $this->root); } /** [class or object, method] of an owned class, or a closure declared in the module namespace inside the root. */ private function ownsCallable(callable $callable): bool { if (is_array($callable) && count($callable) === 2 && is_string($callable[1])) { $class = is_object($callable[0]) ? get_class($callable[0]) : (is_string($callable[0]) ? ltrim($callable[0], '\\') : ''); if ($class === '' || !$this->ownsClass($class)) { return false; } try { $method = new \ReflectionMethod($class, $callable[1]); } catch (\ReflectionException $error) { return false; } $file = $method->getFileName(); return is_string($file) && ModuleRegistry::inside($file, $this->root); } if ($callable instanceof \Closure) { $function = new \ReflectionFunction($callable); $file = $function->getFileName(); $namespace = $function->getNamespaceName() . '\\'; return is_string($file) && strpos($namespace, $this->namespace) === 0 && ModuleRegistry::inside($file, $this->root); } return false; } }