diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 52750af..2f8b644 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -50,7 +50,7 @@ jobs: run: | set -euo pipefail mkdir -p build/evidence - for suite in run catalogue schema-shapes schema-discovery session-storage wire-protocol stdio-endurance execution actions write-verification message-snapshot article-deletion custom-fields field-defaults template-styles menu-components planned jobs process job-process jcb jcb-plan-preview protocol http ownership native/run release; do + for suite in run catalogue catalogue-refresh schema-shapes schema-discovery session-storage wire-protocol stdio-endurance execution actions write-verification message-snapshot article-deletion custom-fields field-defaults template-styles menu-components planned jobs process job-process inventory-transport jcb jcb-plan-preview generated-api-catalogue generated-api-transport generated-api-inventory jcb-form-contracts jcb-api-verification protocol http ownership native/run release; do php "tests/$suite.php" | tee "build/evidence/${suite//\//-}.log" done - name: Preserve the tested PHP runtime for reproducible investigation diff --git a/CHANGELOG.md b/CHANGELOG.md index f08084b..07c51af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ ## [[[NEXT_VERSION]]] +### Fix + +- Synchronize full-size installed generated API catalogues with bounded shared form-contract transport and incremental inventory fingerprints. Release the previous catalogue snapshot before refreshing, avoid an unused catalogue preload during synchronization, and reject invalid inventories or failed refreshes before reusing definitions. Preserve every native route and validation policy, and exercise the complete supplied API-enabled JCB package through repeated installed synchronization and HTTP discovery. + +- Preserve effective administrator input restrictions during catalogue upgrades when only the referenced schema was customized. Keep its tool, action, prompt or binding attached to that policy, including hash-detected edits and disabled schemas; verify allowed and denied requests before and after upgrade. + +- Identify Joomla core components through the native core-extension catalogue during API synchronization. Preserve their existing MCP bindings while supporting enabled third-party components independently of uninstall and disable protection flags; exercise registered-route synchronization and nested-filter reads in the installed Joomla and JCB checks. + +- Describe generated API inputs from their installed native forms, including nested subforms, GUID relationships and validation metadata. Generate a required record GUID only when the native contract calls for one, freeze it in the approved plan, and verify writes through an independently bound item read. Preserve omitted PATCH fields and report unverifiable native responses truthfully. + +- Support observed GUID and alternate unique-key routes across installed generated component APIs, with scoped provider permissions, bounded multiselect filters and exact independent item-read bindings. Preserve existing Joomla route encoders and unselected provider definitions during synchronization. + +- Accept bounded nested JSON inputs in the generic API and companion read tools while retaining selected-action validation, existing Joomla MCP behavior and administrator-owned schema policies. ### Addition - Add package-first Joomla setup, administrator-area, token/ACL, tool, confirmed-write, JCB and recovery guidance, with linked AI and direct-client connection instructions. @@ -132,4 +145,3 @@ - External client and remote stdio ownership separated into mcp_client. Development baseline entries describe existing source, not previously published releases. Published immutable tags establish release availability. - diff --git a/README.md b/README.md index b52f805..ac0e0fb 100644 --- a/README.md +++ b/README.md @@ -38,13 +38,15 @@ Published database records define providers, schemas, actions, bindings, tools, HTTP uses Joomla's authenticated API user and intersects component permissions, viewing-access levels, row assets and target-resource ACL. Remote stdio through the external client retains that identity. Direct local Joomla console execution is a separate trusted server track; remote requests cannot select it. Confirmed remote writes require explicit grants, reviewed plans, confirmation, idempotency and read-back. See [security](SECURITY.md). -Joomla core remains usable without JCB. For JCB capabilities, install and enable JCB and its native registration plugins, then refresh definitions using **Operations → Refresh JCB definitions** or: +Joomla core remains usable without JCB. JCB is a required project integration alongside Joomla core. For JCB capabilities, install and enable JCB and its native registration plugins. Synchronize installed component APIs and JCB commands using **Operations → Refresh JCB definitions** or: ```bash php cli/joomla.php joomla:mcp:jcb-sync ``` -Synchronization inspects actual installed JCB API routes and registered command definitions, persists schemas/actions/bindings/targets, preserves administrator customization and refuses unsupported contracts. It creates no invented endpoints when an API distribution is absent. Ordinary discovery reads the stored catalogue; repeat refresh after changing JCB or its registration plugins. Disabling or uninstalling a required registration plugin hides the affected operations from discovery and execution. +Synchronization inspects registered routes for enabled installed third-party components under native administration permission, plus JCB command input definitions when JCB and its command plugin are available. Joomla's native core-extension inventory identifies core components, which retain their existing catalogue; the MCP component is also excluded. Extension protection/locking flags do not determine API permissions. Synchronization persists component-owned schemas, actions, bindings and targets, preserves administrator customization and reports unsupported contracts. An absent API distribution produces no invented endpoints. Ordinary MCP discovery reads the stored catalogue and does not modify it. Repeat synchronization after changing a component or its route/command plugins. Disabling or uninstalling a required registration plugin hides its affected operations from discovery and execution. + +Generated API bindings describe literal native forms, including defaults, conditional rules, relationships and subforms; they support reviewed numeric, GUID and unique-key item routes. Defaults are descriptive and omitted PATCH fields stay omitted. Plans disclose and freeze an automatically generated primary GUID only when the installed create form explicitly requires it without a native default or detected server generation. Independent API read-back verifies resource identity and declared observable values. Native validation, ACL and errors remain authoritative. JCB package `get`, `init`, `pull`, `push`, `reset` and compilation are effectful operations. Plans freeze inputs, options and relevant definition/configuration fingerprints. Native background execution uses isolated PHP workers and durable principal-owned jobs; compiler archives become bounded, hash-verified artifact references. Cancellation and uncertain outcomes retain recovery evidence; cancellation does not roll back side effects. [JCB setup](docs/GETTING-STARTED.md#enable-jcb-operations-and-background-jobs) covers worker prerequisites; the [JCB contract and acceptance matrix](docs/integrations/JCB.md) records tested scenarios and native limitations. @@ -56,11 +58,10 @@ For development or independent component maintenance, a GitHub source ZIP of a r [OctoJPack](https://github.com/octoleo/octojpack) builds the combined package using the fixed `.octojpack` configuration and each extension's latest tag, then publishes it to `mcp_package`. The component version determines the package version. The manual **Release** workflow freezes component changelogs, creates its immutable tag, updates and hashes its own feed with OctoShoom, then publishes the package. The package tag starts its separate feed/hash workflow. [Release instructions](docs/RELEASE.md) cover workflow order and secrets. -Native administration, installed core tests, JCB synchronization and job/artifact runtime are implemented. [Implementation status](docs/IMPLEMENTATION.md) separates historical installed Joomla/JCB evidence from verification of current source and releases. Package availability does not enlarge those runtime-specific verification boundaries. +Native administration, installed core tests, JCB synchronization and job/artifact runtime are implemented. [Implementation status](docs/IMPLEMENTATION.md) separates historical installed Joomla/JCB evidence from verification of current source and releases. Package availability does not enlarge those runtime-specific verification boundaries. The generic adapter supports installed component APIs, native form contracts and GUID/unique-key routes. The schema-only upgrade regression has 130 behavioral checks, invoked by the existing catalogue CI suite; readiness requires the current PR head to pass all checks. Fresh live generated JCB GUID CRUD acceptance and four historical native POST failures remain explicit evidence boundaries. The original migration snapshot is pinned at `joomengine/joomla-mcp@2cff50f4f6b440da3c684f9995a77efad32e1a36`. Imported native handlers retain behavioral contracts and source attribution. Joomla 6 native contracts are authoritative; repository/MVC/XML placement follows JCB's extension-root layout. This is hand-authored JCB-aligned source, not a claim of an imported JCB blueprint. Explicitly unavailable inherited operations are recorded in [migration provenance](docs/migration/README.md). Before changing runtime code, read [architecture](docs/ARCHITECTURE.md), [database design](docs/DATABASE.md), [migration plan](docs/MIGRATION.md), [security](SECURITY.md) and [agent instructions](AGENTS.md). User-facing resource details include [custom fields](docs/CUSTOM-FIELDS.md) and [native Joomla API limitations](docs/testing/native-api-limitations.md). Changes are recorded in [CHANGELOG.md](CHANGELOG.md) and [changelog.xml](changelog.xml). Pending entries use `[[[NEXT_VERSION]]]`; the release workflow assigns their version. - diff --git a/admin/cli/jcb.php b/admin/cli/jcb.php index 85f6189..5bddcc1 100644 --- a/admin/cli/jcb.php +++ b/admin/cli/jcb.php @@ -11,6 +11,7 @@ use Joomla\Application\Event\ApplicationEvent; use Joomla\CMS\Application\ApiApplication; use Joomla\CMS\Event\Application\BeforeApiRouteEvent; +use Joomla\CMS\Extension\ExtensionHelper; use Joomla\CMS\Factory; use Joomla\CMS\Language\LanguageFactoryInterface; use Joomla\CMS\Plugin\PluginHelper; @@ -27,12 +28,16 @@ use VDM\Component\JoomEngineMcp\Administrator\Console\WorkerApplication; use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; use VDM\Component\JoomEngineMcp\Administrator\Jcb\ApiRegistry; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\CatalogueBuilder; use VDM\Component\JoomEngineMcp\Administrator\Jcb\CommandOutput; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\GeneratedApiInventory; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\InventoryTransport; use VDM\Component\JoomEngineMcp\Administrator\Jcb\RegistrationObserver; use VDM\Component\JoomEngineMcp\Administrator\Jcb\Worker; use VDM\Component\JoomEngineMcp\Administrator\Security\ConsoleIdentity; use VDM\Component\JoomEngineMcp\Administrator\Security\JoomlaPrincipal; use VDM\Component\JoomEngineMcp\Administrator\Security\LocalPrincipal; +use VDM\Component\JoomEngineMcp\Administrator\Service\Json; if (PHP_SAPI !== 'cli') { @@ -45,6 +50,7 @@ // MCP artifact storage applies its own explicit private directory/file modes. $level = ob_get_level(); ob_start(static fn (string $output): string => '', 4096); +$json = null; try { @@ -99,7 +105,8 @@ $principal = new JoomlaPrincipal($user); $app->loadIdentity($user); - if (!$principal->authorise('mcp.access', 'com_joomengine_mcp') || !$principal->authorise('core.admin', 'com_componentbuilder')) + $asset = $request['operation'] === 'jcb.inventory' ? 'com_joomengine_mcp' : 'com_componentbuilder'; + if (!$principal->authorise('mcp.access', 'com_joomengine_mcp') || !$principal->authorise('core.admin', $asset)) { throw new OperationException('JCB_ACCESS_DENIED', 'The original Joomla user no longer authorizes JCB execution.'); } @@ -142,17 +149,37 @@ if ($request['operation'] === 'jcb.inventory') { $commands = $worker->inventory(); + if (!$principal->isLocal() && CatalogueBuilder::hasCommandScope($commands) + && !$principal->authorise('core.admin', 'com_componentbuilder')) + { + throw new OperationException('JCB_CATALOGUE_DENIED', 'Native JCB administration permission is required to synchronize registered JCB commands.'); + } $api = $container->get(ApiApplication::class); $api->loadIdentity($app->getIdentity()); Factory::$application = $api; try { + $database = $container->get(DatabaseInterface::class); + $query = $database->createQuery()->select($database->quoteName(['type', 'element', 'enabled'])) + ->from($database->quoteName('#__extensions')) + ->where($database->quoteName('type') . ' = ' . $database->quote('component')); + $coreComponents = array_map(static fn (array $extension): string => $extension[1], array_filter( + ExtensionHelper::getCoreExtensions(), static fn (array $extension): bool => $extension[0] === 'component')); + $components = GeneratedApiInventory::components($database->setQuery($query)->loadAssocList(), $coreComponents); + $components = array_values(array_filter($components, static fn (string $component): bool => + $principal->isLocal() || $principal->authorise('core.admin', $component))); + if (in_array('com_componentbuilder', $components, true)) + { + $commands['component'] = 'com_componentbuilder'; + } $router = new ApiRouter($api); PluginHelper::importPlugin('webservices', null, true, $dispatcher); $owners = $observer->dispatch($dispatcher, new BeforeApiRouteEvent('onBeforeApiRoute', ['router' => $router, 'subject' => $api]), static fn (): array => $router->getRoutes()); - $result = ['commands' => $commands, 'api' => (new ApiRegistry($router, $owners))->inventory()]; + $inventory = (new ApiRegistry($router, $owners, $components))->inventory(); + $result = ['commands' => $commands, 'api' => GeneratedApiInventory::enrich($inventory, + JPATH_ADMINISTRATOR . '/components', JPATH_ROOT . '/api/components')]; } finally { @@ -169,9 +196,19 @@ } $result = ['protocol' => 'joomengine-worker/1'] + $result; + if ($request['operation'] === 'jcb.inventory' && isset($request['inventory_format'])) + { + if ($request['inventory_format'] !== InventoryTransport::FORMAT) + { + throw new OperationException('JCB_INVENTORY_INVALID', 'The requested native inventory encoding is unsupported.'); + } + $result = InventoryTransport::pack($result); + $json = Json::encode($result, InventoryTransport::MAX_WIRE_BYTES); + } } catch (Throwable $error) { + $json = null; $result = ['protocol' => 'joomengine-worker/1', 'error' => $error instanceof OperationException ? $error->toArray() : ['code' => 'JCB_WORKER_FAILED', 'message' => 'The native JCB worker could not complete this request.']]; } @@ -181,7 +218,7 @@ ob_end_clean(); } -$json = json_encode($result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); +$json ??= json_encode($result, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR); for ($offset = 0, $length = strlen($json); $offset < $length; $offset += $written) { $written = fwrite(STDOUT, substr($json, $offset)); diff --git a/admin/data/catalogue-seed.json b/admin/data/catalogue-seed.json index 2048e01..4651f05 100644 --- a/admin/data/catalogue-seed.json +++ b/admin/data/catalogue-seed.json @@ -1,6 +1,6 @@ { "source": "2cff50f4f6b440da3c684f9995a77efad32e1a36", - "runtimeSource": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "runtimeSource": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "entities": { "provider": [ { @@ -8988,7 +8988,7 @@ "name": "schema.15fb5389bc29b0c83f4bf179870c7f89775fbe0da8e1d24f43af140b462d2049", "title": "Schema 15fb5389bc29", "document": "{\"type\":\"object\",\"properties\":{\"site\":{\"description\":\"Configured site alias; omit for the default site.\",\"type\":\"string\",\"minLength\":1},\"toolsets\":{\"description\":\"Enabled write toolset names from this server's action catalogue. Each scope is checked against the current Joomla identity.\",\"type\":\"array\",\"minItems\":1,\"maxItems\":64,\"items\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":190}},\"duration\":{\"type\":\"string\",\"enum\":[\"once\",\"30-minutes\",\"indefinite\"]},\"reason\":{\"type\":\"string\",\"minLength\":3,\"maxLength\":500}},\"required\":[\"toolsets\",\"duration\",\"reason\"],\"$schema\":\"http://json-schema.org/draft-07/schema#\"}", - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -9007,10 +9007,54 @@ { "id": 409, "provider_id": 1, + "name": "schema.c36a04dc5114ed333c8e8d846cdddd9b21f3b50075bcecb0e951df2853e55f7f", + "title": "Schema c36a04dc5114", + "document": "{\"type\":\"object\",\"properties\":{\"site\":{\"description\":\"Configured site alias; omit for the default site.\",\"type\":\"string\",\"minLength\":1},\"action\":{\"type\":\"string\",\"minLength\":3,\"maxLength\":160},\"input\":{\"description\":\"Bounded JSON argument object validated again against the selected action schema. Nested objects and arrays retain their JSON types.\",\"default\":{},\"type\":\"object\",\"maxProperties\":512,\"propertyNames\":{\"type\":\"string\"},\"additionalProperties\":{}},\"transport\":{\"default\":\"auto\",\"type\":\"string\",\"enum\":[\"auto\",\"api\",\"cli\"]}},\"required\":[\"action\"],\"$schema\":\"http://json-schema.org/draft-07/schema#\"}", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", + "asset_id": 0, + "published": 1, + "access": 1, + "ordering": 0, + "checked_out": null, + "checked_out_time": null, + "created": "2026-09-17 00:00:00", + "created_by": 0, + "modified": null, + "modified_by": 0, + "version": 1, + "params": "{}", + "seed_hash": "c4f360e0818319706398fe921e3cd4bdb05475c258db491200573ed1031db7c9", + "customized": 0 + }, + { + "id": 410, + "provider_id": 1, + "name": "schema.d5e18a6816a315862f4898d7eb178d8ab5754f88a1a9168cb3f3b7ac155d250b", + "title": "Schema d5e18a6816a3", + "document": "{\"type\":\"object\",\"properties\":{\"site\":{\"description\":\"Configured site alias; omit for the default site.\",\"type\":\"string\",\"minLength\":1},\"action\":{\"type\":\"string\",\"minLength\":3,\"maxLength\":160},\"input\":{\"description\":\"Bounded JSON argument object validated again against the selected native action schema. Nested objects and arrays retain their JSON types.\",\"default\":{},\"type\":\"object\",\"maxProperties\":512,\"propertyNames\":{\"type\":\"string\"},\"additionalProperties\":{}}},\"required\":[\"action\"],\"$schema\":\"http://json-schema.org/draft-07/schema#\"}", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", + "asset_id": 0, + "published": 1, + "access": 1, + "ordering": 0, + "checked_out": null, + "checked_out_time": null, + "created": "2026-09-17 00:00:00", + "created_by": 0, + "modified": null, + "modified_by": 0, + "version": 1, + "params": "{}", + "seed_hash": "19e026f56233b39a274c80f259062dab39ee425d7143c3f030e58b9a54164a7f", + "customized": 0 + }, + { + "id": 411, + "provider_id": 1, "name": "schema.37cde43b5df6f7f600f1f4410f2a4706d53e935460124c699cab61e68f36aee3", "title": "Schema 37cde43b5df6", "document": "{\"type\":\"object\",\"properties\":{\"data\":{\"type\":\"object\",\"minProperties\":1,\"maxProperties\":512,\"properties\":{\"title\":{\"description\":\"Reviewed Joomla site module form field.\"},\"note\":{\"description\":\"Reviewed Joomla site module form field.\"},\"content\":{\"description\":\"Reviewed Joomla site module form field.\"},\"ordering\":{\"type\":\"integer\",\"description\":\"Use zero or omit ordering to let Joomla assign the next order for this module position. A nonzero ordering requests that exact value.\"},\"position\":{\"description\":\"Reviewed Joomla site module form field.\"},\"published\":{\"description\":\"Reviewed Joomla site module form field.\"},\"module\":{\"description\":\"Reviewed Joomla site module form field.\"},\"access\":{\"description\":\"Reviewed Joomla site module form field.\"},\"showtitle\":{\"description\":\"Reviewed Joomla site module form field.\"},\"params\":{\"description\":\"Reviewed Joomla site module form field.\"},\"language\":{\"description\":\"Reviewed Joomla site module form field.\"},\"assigned\":{\"description\":\"Reviewed Joomla site module form field.\"}},\"additionalProperties\":false,\"description\":\"Allowlisted Joomla form JSON. Joomla validates resource-specific values and ACL.\"}},\"required\":[\"data\"],\"additionalProperties\":false}", - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -9027,12 +9071,12 @@ "customized": 0 }, { - "id": 410, + "id": 412, "provider_id": 1, "name": "schema.edd0e2e7b8da488986a2b08ed09766131403db7f5719abb2a007401f8b6df409", "title": "Schema edd0e2e7b8da", "document": "{\"type\":\"object\",\"properties\":{\"data\":{\"type\":\"object\",\"minProperties\":1,\"maxProperties\":512,\"properties\":{\"title\":{\"description\":\"Reviewed Joomla administrator module form field.\"},\"note\":{\"description\":\"Reviewed Joomla administrator module form field.\"},\"content\":{\"description\":\"Reviewed Joomla administrator module form field.\"},\"ordering\":{\"type\":\"integer\",\"description\":\"Use zero or omit ordering to let Joomla assign the next order for this module position. A nonzero ordering requests that exact value.\"},\"position\":{\"description\":\"Reviewed Joomla administrator module form field.\"},\"published\":{\"description\":\"Reviewed Joomla administrator module form field.\"},\"module\":{\"description\":\"Reviewed Joomla administrator module form field.\"},\"access\":{\"description\":\"Reviewed Joomla administrator module form field.\"},\"showtitle\":{\"description\":\"Reviewed Joomla administrator module form field.\"},\"params\":{\"description\":\"Reviewed Joomla administrator module form field.\"},\"language\":{\"description\":\"Reviewed Joomla administrator module form field.\"},\"assigned\":{\"description\":\"Reviewed Joomla administrator module form field.\"}},\"additionalProperties\":false,\"description\":\"Allowlisted Joomla form JSON. Joomla validates resource-specific values and ACL.\"}},\"required\":[\"data\"],\"additionalProperties\":false}", - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -9049,12 +9093,12 @@ "customized": 0 }, { - "id": 411, + "id": 413, "provider_id": 1, "name": "schema.514ff59fe2ec7de14b1a0bb8dce0754164a69ca1e795605c6afc13cfd2b63159", "title": "Schema 514ff59fe2ec", "document": "{\"type\":\"object\",\"properties\":{\"site\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":190,\"description\":\"Configured installation alias; omit to use this server.\"},\"limit\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":100,\"default\":50},\"offset\":{\"type\":\"integer\",\"minimum\":0,\"maximum\":1000000,\"default\":0}},\"required\":[],\"additionalProperties\":false}", - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -9071,12 +9115,12 @@ "customized": 0 }, { - "id": 412, + "id": 414, "provider_id": 1, "name": "schema.33d5e81d0321fc7ccb4e3210a6b2e80afa0ddc60d2e7307bc47f02f46330ec4a", "title": "Schema 33d5e81d0321", "document": "{\"type\":\"object\",\"properties\":{\"site\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":190,\"description\":\"Configured installation alias; omit to use this server.\"},\"jobId\":{\"type\":\"string\",\"pattern\":\"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$\",\"description\":\"Opaque identifier returned by this server for the authenticated principal.\"}},\"required\":[\"jobId\"],\"additionalProperties\":false}", - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -9093,12 +9137,12 @@ "customized": 0 }, { - "id": 413, + "id": 415, "provider_id": 1, "name": "schema.40995628f009ab15cecc1a0ca487ca74c7577b0331d36ab1e4eeeeca9de5460b", "title": "Schema 40995628f009", "document": "{\"type\":\"object\",\"properties\":{\"site\":{\"type\":\"string\",\"minLength\":1,\"maxLength\":190,\"description\":\"Configured installation alias; omit to use this server.\"},\"artifactId\":{\"type\":\"string\",\"pattern\":\"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$\",\"description\":\"Opaque identifier returned by this server for the authenticated principal.\"},\"offset\":{\"type\":\"integer\",\"minimum\":0,\"default\":0},\"length\":{\"type\":\"integer\",\"minimum\":1,\"maximum\":262144,\"default\":65536}},\"required\":[\"artifactId\"],\"additionalProperties\":false}", - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -14987,7 +15031,7 @@ "input_schema_id": 10, "output_schema_id": 1, "definition": "{\"id\":\"messages.messages.get\",\"title\":\"Get Private message\",\"description\":\"Gets one private message by numeric identifier through Joomla's native API. The native item GET marks the stored message read and may return the representation loaded before that change. Eligible local, uncustomized write planning uses a separately declared recipient-scoped native-owned-record snapshot without marking the message read.\",\"domain\":\"messages\",\"operation\":\"get\",\"method\":\"GET\",\"routeTemplate\":\"v1/messages/:id\",\"routeParameters\":[{\"name\":\"id\",\"kind\":\"positive-integer\",\"required\":true,\"maximumLength\":16}],\"paginated\":false,\"toolset\":\"users.read\",\"risk\":\"sensitive-read\",\"sideEffect\":true,\"acl\":{\"apiLogin\":\"core.login.api\",\"component\":\"com_messages\",\"enforcement\":\"joomla-controller\",\"resourceScoped\":true,\"actionHints\":{\"read\":[\"core.manage\"],\"list\":[\"core.manage\"],\"get\":[\"core.manage\"],\"create\":[\"core.create\"],\"update\":[\"core.edit\",\"core.edit.own\"],\"delete\":[\"core.delete\"]}},\"driver\":{\"kind\":\"joomla-api\",\"transport\":\"https\",\"authentication\":\"joomla-api-token\",\"plugin\":\"webservices/messages\",\"responseShape\":\"json-api\",\"mutationBody\":\"not-applicable\"},\"versions\":{\"baseline\":\"6.1-dev\",\"compatible\":\"6.2-dev\",\"canary\":\"7.0-dev\",\"minimum\":\"6.1.0\",\"maximumExclusive\":\"8.0.0\",\"examinedHeads\":{\"6.1-dev\":\"071afb7ad305c02983a653ccfc301b5c8360264b\",\"6.2-dev\":\"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5\",\"7.0-dev\":\"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f\"}},\"source\":{\"repository\":\"joomla/joomla-cms\",\"branch\":\"6.1-dev\",\"commit\":\"071afb7ad305c02983a653ccfc301b5c8360264b\",\"path\":\"plugins/webservices/messages/src/Extension/Messages.php\",\"registration\":\"createCRUDRoutes\"},\"sourceGate\":null}", - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -26468,7 +26512,7 @@ "configuration": "{\"method\":\"DELETE\",\"route\":\"/v1/messages/:id\",\"route_parameters\":[{\"name\":\"id\",\"kind\":\"positive-integer\",\"required\":true,\"maximumLength\":16}],\"paginated\":false,\"body_policy\":\"none\",\"operation\":\"delete\",\"body_defaults\":[],\"query_defaults\":{},\"preserve_fields\":[],\"derived_fields\":[],\"authentication\":\"joomla-api-token\",\"response_shape\":\"json-api\",\"source_gate\":null,\"read_action\":\"messages.messages.get\",\"mutation_rule\":null,\"snapshot_contract\":\"joomla.message-owned-record.v1\"}", "definition": "{\"source\":{\"repository\":\"joomla/joomla-cms\",\"branch\":\"6.1-dev\",\"commit\":\"071afb7ad305c02983a653ccfc301b5c8360264b\",\"path\":\"plugins/webservices/messages/src/Extension/Messages.php\",\"registration\":\"createCRUDRoutes\"},\"acl\":{\"apiLogin\":\"core.login.api\",\"component\":\"com_messages\",\"enforcement\":\"joomla-controller\",\"resourceScoped\":true,\"actionHints\":{\"read\":[\"core.manage\"],\"list\":[\"core.manage\"],\"get\":[\"core.manage\"],\"create\":[\"core.create\"],\"update\":[\"core.edit\",\"core.edit.own\"],\"delete\":[\"core.delete\"]}},\"required_extensions\":[\"webservices/messages\",\"com_messages\"]}", "published": 1, - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "access": 1, "ordering": 0, @@ -26524,7 +26568,7 @@ "configuration": "{\"method\":\"GET\",\"route\":\"/v1/messages/:id\",\"route_parameters\":[{\"name\":\"id\",\"kind\":\"positive-integer\",\"required\":true,\"maximumLength\":16}],\"paginated\":false,\"body_policy\":\"none\",\"operation\":\"get\",\"body_defaults\":[],\"query_defaults\":{},\"preserve_fields\":[],\"derived_fields\":[],\"authentication\":\"joomla-api-token\",\"response_shape\":\"json-api\",\"source_gate\":null,\"mutation_rule\":null,\"snapshot_contract\":\"joomla.message-owned-record.v1\"}", "definition": "{\"source\":{\"repository\":\"joomla/joomla-cms\",\"branch\":\"6.1-dev\",\"commit\":\"071afb7ad305c02983a653ccfc301b5c8360264b\",\"path\":\"plugins/webservices/messages/src/Extension/Messages.php\",\"registration\":\"createCRUDRoutes\"},\"acl\":{\"apiLogin\":\"core.login.api\",\"component\":\"com_messages\",\"enforcement\":\"joomla-controller\",\"resourceScoped\":true,\"actionHints\":{\"read\":[\"core.manage\"],\"list\":[\"core.manage\"],\"get\":[\"core.manage\"],\"create\":[\"core.create\"],\"update\":[\"core.edit\",\"core.edit.own\"],\"delete\":[\"core.delete\"]}},\"required_extensions\":[\"webservices/messages\",\"com_messages\"]}", "published": 1, - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "access": 1, "ordering": 0, @@ -26636,7 +26680,7 @@ "configuration": "{\"method\":\"PATCH\",\"route\":\"/v1/messages/:id\",\"route_parameters\":[{\"name\":\"id\",\"kind\":\"positive-integer\",\"required\":true,\"maximumLength\":16}],\"paginated\":false,\"body_policy\":\"required\",\"operation\":\"update\",\"body_defaults\":[],\"query_defaults\":{},\"preserve_fields\":[],\"derived_fields\":[],\"authentication\":\"joomla-api-token\",\"response_shape\":\"json-api\",\"source_gate\":null,\"read_action\":\"messages.messages.get\",\"mutation_rule\":null,\"snapshot_contract\":\"joomla.message-owned-record.v1\"}", "definition": "{\"source\":{\"repository\":\"joomla/joomla-cms\",\"branch\":\"6.1-dev\",\"commit\":\"071afb7ad305c02983a653ccfc301b5c8360264b\",\"path\":\"plugins/webservices/messages/src/Extension/Messages.php\",\"registration\":\"createCRUDRoutes\"},\"acl\":{\"apiLogin\":\"core.login.api\",\"component\":\"com_messages\",\"enforcement\":\"joomla-controller\",\"resourceScoped\":true,\"actionHints\":{\"read\":[\"core.manage\"],\"list\":[\"core.manage\"],\"get\":[\"core.manage\"],\"create\":[\"core.create\"],\"update\":[\"core.edit\",\"core.edit.own\"],\"delete\":[\"core.delete\"]}},\"required_extensions\":[\"webservices/messages\",\"com_messages\"]}", "published": 1, - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "access": 1, "ordering": 0, @@ -26715,12 +26759,12 @@ "title": "modules.administrator.create (API)", "track": "api", "handler": "api.request", - "input_schema_id": 410, + "input_schema_id": 412, "output_schema_id": 1, "configuration": "{\"method\":\"POST\",\"route\":\"/v1/modules/administrator\",\"route_parameters\":[],\"paginated\":false,\"body_policy\":\"required\",\"operation\":\"create\",\"body_defaults\":{\"client_id\":1},\"query_defaults\":{},\"preserve_fields\":[\"params\",\"assigned\"],\"derived_fields\":[\"module_assignment\"],\"authentication\":\"joomla-api-token\",\"response_shape\":\"json-api\",\"source_gate\":null,\"read_action\":\"modules.administrator.get\",\"mutation_rule\":null}", "definition": "{\"source\":{\"repository\":\"joomla/joomla-cms\",\"branch\":\"6.1-dev\",\"commit\":\"071afb7ad305c02983a653ccfc301b5c8360264b\",\"path\":\"plugins/webservices/modules/src/Extension/Modules.php\",\"registration\":\"createCRUDRoutes\"},\"acl\":{\"apiLogin\":\"core.login.api\",\"component\":\"com_modules\",\"enforcement\":\"joomla-controller\",\"resourceScoped\":true,\"actionHints\":{\"read\":[\"core.manage\"],\"list\":[\"core.manage\"],\"get\":[\"core.manage\"],\"create\":[\"core.create\"],\"update\":[\"core.edit\",\"core.edit.own\"],\"delete\":[\"core.delete\"]}},\"required_extensions\":[\"webservices/modules\",\"com_modules\"]}", "published": 1, - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "access": 1, "ordering": 0, @@ -26732,7 +26776,7 @@ "modified_by": 0, "version": 1, "params": "{}", - "seed_hash": "20fb6d299eb279dac608c98c7e5a976805bb14f3cd51002b7da70314ea6c7c36", + "seed_hash": "2217f95665684e293ee598882493725504b81d4724c8f864a797ad3775143fd8", "customized": 0 }, { @@ -27051,12 +27095,12 @@ "title": "modules.site.create (API)", "track": "api", "handler": "api.request", - "input_schema_id": 409, + "input_schema_id": 411, "output_schema_id": 1, "configuration": "{\"method\":\"POST\",\"route\":\"/v1/modules/site\",\"route_parameters\":[],\"paginated\":false,\"body_policy\":\"required\",\"operation\":\"create\",\"body_defaults\":{\"client_id\":0},\"query_defaults\":{},\"preserve_fields\":[\"params\",\"assigned\"],\"derived_fields\":[\"module_assignment\"],\"authentication\":\"joomla-api-token\",\"response_shape\":\"json-api\",\"source_gate\":null,\"read_action\":\"modules.site.get\",\"mutation_rule\":null}", "definition": "{\"source\":{\"repository\":\"joomla/joomla-cms\",\"branch\":\"6.1-dev\",\"commit\":\"071afb7ad305c02983a653ccfc301b5c8360264b\",\"path\":\"plugins/webservices/modules/src/Extension/Modules.php\",\"registration\":\"createCRUDRoutes\"},\"acl\":{\"apiLogin\":\"core.login.api\",\"component\":\"com_modules\",\"enforcement\":\"joomla-controller\",\"resourceScoped\":true,\"actionHints\":{\"read\":[\"core.manage\"],\"list\":[\"core.manage\"],\"get\":[\"core.manage\"],\"create\":[\"core.create\"],\"update\":[\"core.edit\",\"core.edit.own\"],\"delete\":[\"core.delete\"]}},\"required_extensions\":[\"webservices/modules\",\"com_modules\"]}", "published": 1, - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "access": 1, "ordering": 0, @@ -27068,7 +27112,7 @@ "modified_by": 0, "version": 1, "params": "{}", - "seed_hash": "c56d24856391ea2a2705cab8abe6419c55201a4597d1fa1a67002842ab655efa", + "seed_hash": "65a2a4db22147683c65ef3fdda4571c630e12d61f8533d66091143f9d4914cb6", "customized": 0 }, { @@ -30576,10 +30620,11 @@ "title": "Run an enabled Joomla read action", "description": "Executes one catalogue action by semantic identifier. Routes, origins, credentials, methods, and toolsets remain server controlled. Some source-defined GET operations perform Joomla bookkeeping; inspect the action descriptor before execution.", "handler": "action.read", - "input_schema_id": 393, + "input_schema_id": 409, "output_schema_id": null, "configuration": "{}", "definition": "{\"name\":\"joomla_action_read\",\"title\":\"Run an enabled Joomla read action\",\"description\":\"Executes one catalogue action by semantic identifier. Routes, origins, credentials, methods, and toolsets remain server controlled. Some source-defined GET operations perform Joomla bookkeeping; inspect the action descriptor before execution.\",\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":false,\"idempotentHint\":true,\"openWorldHint\":false},\"execution\":{\"taskSupport\":\"forbidden\"}}", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -30592,8 +30637,7 @@ "modified_by": 0, "version": 1, "params": "{}", - "seed_revision": "2cff50f4f6b440da3c684f9995a77efad32e1a36", - "seed_hash": "c97872a08182eab21bf1f291ad0137069a8c3475b9fc1df4b16c49da292b3467", + "seed_hash": "f57b4fc43398a38c2d965ec05a547defa84f066004276a5fd51204e3aa3b33c3", "customized": 0 }, { @@ -30607,7 +30651,7 @@ "output_schema_id": null, "configuration": "{}", "definition": "{\"name\":\"joomla_permission_request\",\"title\":\"Request permission for Joomla writes\",\"description\":\"Creates a principal-bound operator permission request for selected configured write or administration toolsets. Returns an exact acknowledgement phrase that must be shown to and supplied by the operator. It does not grant permission.\",\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":false,\"idempotentHint\":false,\"openWorldHint\":false},\"execution\":{\"taskSupport\":\"forbidden\"}}", - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -30981,10 +31025,11 @@ "title": "Run a Joomla-native companion read action", "description": "Runs one fixed Joomla-native structured read action through JSON stdin. No Joomla command, shell, path, URL, or credential is accepted.", "handler": "action.read", - "input_schema_id": 403, + "input_schema_id": 410, "output_schema_id": null, "configuration": "{\"tracks\":[\"cli\"],\"transport\":\"cli\"}", "definition": "{\"name\":\"joomla_companion_action_read\",\"title\":\"Run a Joomla-native companion read action\",\"description\":\"Runs one fixed Joomla-native structured read action through JSON stdin. No Joomla command, shell, path, URL, or credential is accepted.\",\"annotations\":{\"readOnlyHint\":true,\"destructiveHint\":false,\"idempotentHint\":true,\"openWorldHint\":false},\"execution\":{\"taskSupport\":\"forbidden\"}}", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -30997,8 +31042,7 @@ "modified_by": 0, "version": 1, "params": "{}", - "seed_revision": "2cff50f4f6b440da3c684f9995a77efad32e1a36", - "seed_hash": "4d0ad1be7d4261ac5fad1dd32a0e480a1866a8581cf241d523da657ef6de9817", + "seed_hash": "cffba457e204f5f19cd46495c77b6804ae142db56569fd8dd86c20868b28ac4d", "customized": 0 }, { @@ -31116,11 +31160,11 @@ "title": "List owned jobs", "description": "List durable jobs belonging to the current authenticated principal. Status includes known cancellation, completion and uncertain outcomes.", "handler": "job.list", - "input_schema_id": 411, + "input_schema_id": 413, "output_schema_id": 1, "configuration": "{}", "definition": "{\"name\":\"joomla_jobs_list\",\"title\":\"List owned jobs\",\"description\":\"List durable jobs belonging to the current authenticated principal. Status includes known cancellation, completion and uncertain outcomes.\",\"annotations\":{\"readOnlyHint\":true,\"destructiveHint\":false,\"idempotentHint\":true,\"openWorldHint\":false},\"execution\":{\"taskSupport\":\"forbidden\"}}", - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -31133,7 +31177,7 @@ "modified_by": 0, "version": 1, "params": "{}", - "seed_hash": "7395ef732a01cc75e158634613b96a931ee7353e2d9d80c27c3a538e528b697a", + "seed_hash": "39a0dcca02fe5fa9f11378a190f85e5642ce97ac8f9963d345f9d6c36680ffcf", "customized": 0 }, { @@ -31143,11 +31187,11 @@ "title": "Inspect an owned job", "description": "Read the current durable state and retained result of an owned job. An expired worker lease is reported as uncertain; a disconnected request is not proof of rollback.", "handler": "job.status", - "input_schema_id": 412, + "input_schema_id": 414, "output_schema_id": 1, "configuration": "{}", "definition": "{\"name\":\"joomla_job_status\",\"title\":\"Inspect an owned job\",\"description\":\"Read the current durable state and retained result of an owned job. An expired worker lease is reported as uncertain; a disconnected request is not proof of rollback.\",\"annotations\":{\"readOnlyHint\":true,\"destructiveHint\":false,\"idempotentHint\":true,\"openWorldHint\":false},\"execution\":{\"taskSupport\":\"forbidden\"}}", - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -31160,7 +31204,7 @@ "modified_by": 0, "version": 1, "params": "{}", - "seed_hash": "0698a762b00e4a519c1c1464c0a4068d513eb704dca480d4ee28deadf2a5fc43", + "seed_hash": "a55b11a0897352872ee04eb46884d3710c96fe31bb287079fc31615caf2bfab1", "customized": 0 }, { @@ -31170,11 +31214,11 @@ "title": "Cancel an owned job", "description": "Request cancellation of an owned job. Queued work is cancelled before execution; running work may have partial effects. Inspect the returned state and reconcile uncertain outcomes.", "handler": "job.cancel", - "input_schema_id": 412, + "input_schema_id": 414, "output_schema_id": 1, "configuration": "{}", "definition": "{\"name\":\"joomla_job_cancel\",\"title\":\"Cancel an owned job\",\"description\":\"Request cancellation of an owned job. Queued work is cancelled before execution; running work may have partial effects. Inspect the returned state and reconcile uncertain outcomes.\",\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":true,\"idempotentHint\":false,\"openWorldHint\":false},\"execution\":{\"taskSupport\":\"forbidden\"}}", - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -31187,7 +31231,7 @@ "modified_by": 0, "version": 1, "params": "{}", - "seed_hash": "911f92ddcc571a7af996c3c0177cbf3af3c4a62e7dbb660803d6a80feab0032a", + "seed_hash": "b5c44d8938d400ab7190dc2e3cce9e7f673b25a2ba1a5a632579051bcd88d54d", "customized": 0 }, { @@ -31197,11 +31241,11 @@ "title": "Redispatch an unstarted job", "description": "Redispatch only a never-started queued job using its existing approved execution claim. Running, cancelled and uncertain jobs cannot be replayed.", "handler": "job.redispatch", - "input_schema_id": 412, + "input_schema_id": 414, "output_schema_id": 1, "configuration": "{}", "definition": "{\"name\":\"joomla_job_redispatch\",\"title\":\"Redispatch an unstarted job\",\"description\":\"Redispatch only a never-started queued job using its existing approved execution claim. Running, cancelled and uncertain jobs cannot be replayed.\",\"annotations\":{\"readOnlyHint\":false,\"destructiveHint\":true,\"idempotentHint\":false,\"openWorldHint\":false},\"execution\":{\"taskSupport\":\"forbidden\"}}", - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -31214,7 +31258,7 @@ "modified_by": 0, "version": 1, "params": "{}", - "seed_hash": "490f9f518e87b7529cff2082eb024cf877f4384059072e256330ea23062f987a", + "seed_hash": "46d1a5dd2902850669093bfeaad5e31f2c7b8a7d6796a7948c4b9986fa9013ec", "customized": 0 }, { @@ -31224,11 +31268,11 @@ "title": "List owned job artifacts", "description": "List immutable retained artifact metadata for an owned job. The server returns opaque artifact identifiers, never selectable filesystem paths.", "handler": "job.artifacts", - "input_schema_id": 412, + "input_schema_id": 414, "output_schema_id": 1, "configuration": "{}", "definition": "{\"name\":\"joomla_job_artifacts\",\"title\":\"List owned job artifacts\",\"description\":\"List immutable retained artifact metadata for an owned job. The server returns opaque artifact identifiers, never selectable filesystem paths.\",\"annotations\":{\"readOnlyHint\":true,\"destructiveHint\":false,\"idempotentHint\":true,\"openWorldHint\":false},\"execution\":{\"taskSupport\":\"forbidden\"}}", - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -31241,7 +31285,7 @@ "modified_by": 0, "version": 1, "params": "{}", - "seed_hash": "e0a2fb614aab6af1f86c9c1dc827032e626ece4172edf82115c0e37a46cf2fda", + "seed_hash": "e39a2637af0636d020d420a674a8c02a6958dd3e90e8a405f46c85d86631e70b", "customized": 0 }, { @@ -31251,11 +31295,11 @@ "title": "Read an owned artifact chunk", "description": "Read and verify a bounded base64-encoded chunk of a retained owned artifact. Use the returned offsets and hashes to assemble the file; no local or remote path input is accepted.", "handler": "job.artifact.read", - "input_schema_id": 413, + "input_schema_id": 415, "output_schema_id": 1, "configuration": "{}", "definition": "{\"name\":\"joomla_job_artifact_read\",\"title\":\"Read an owned artifact chunk\",\"description\":\"Read and verify a bounded base64-encoded chunk of a retained owned artifact. Use the returned offsets and hashes to assemble the file; no local or remote path input is accepted.\",\"annotations\":{\"readOnlyHint\":true,\"destructiveHint\":false,\"idempotentHint\":true,\"openWorldHint\":false},\"execution\":{\"taskSupport\":\"forbidden\"}}", - "seed_revision": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "seed_revision": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "asset_id": 0, "published": 1, "access": 1, @@ -31268,7 +31312,7 @@ "modified_by": 0, "version": 1, "params": "{}", - "seed_hash": "8ce7e9b05ffad0d9aef126b8063c06be324faa825c2faa70265db587c728ad93", + "seed_hash": "ca08e219ef0bc569115777d4d205c45316d101ad40ab9927bca39b5eb6327137", "customized": 0 } ], diff --git a/admin/language/en-GB/com_joomengine_mcp.ini b/admin/language/en-GB/com_joomengine_mcp.ini index cd7c63c..e7c3e59 100644 --- a/admin/language/en-GB/com_joomengine_mcp.ini +++ b/admin/language/en-GB/com_joomengine_mcp.ini @@ -81,7 +81,7 @@ COM_JOOMENGINE_MCP_FIELD_VERSION="Revision" COM_JOOMENGINE_MCP_GRANTS="Permission grants" COM_JOOMENGINE_MCP_IDENTIFIER_HELP="Stable unique identifier. Renaming a shipped definition changes its public protocol identity." COM_JOOMENGINE_MCP_INVALID_STATE="The requested publication state is invalid." -COM_JOOMENGINE_MCP_JCB_SYNCHRONIZED="The installed JCB catalogue definitions were refreshed." +COM_JOOMENGINE_MCP_JCB_SYNCHRONIZED="The installed component API and JCB command definitions were refreshed." COM_JOOMENGINE_MCP_JOBS="Jobs" COM_JOOMENGINE_MCP_JOB_CANCELLATION_DESC="Queued cancellation prevents the operation from starting. Running cancellation asks its worker to stop; it does not roll back changes. Review uncertain or partial effects in Executions." COM_JOOMENGINE_MCP_JOB_CANCELLATION_REQUESTED="The job cancellation request was recorded." @@ -177,8 +177,8 @@ COM_JOOMENGINE_MCP_SCHEMA_N_ITEMS_UNPUBLISHED_1="Definition unpublished." COM_JOOMENGINE_MCP_SESSION_TTL="Protocol session lifetime (seconds)" COM_JOOMENGINE_MCP_SHIPPED="Shipped definition" COM_JOOMENGINE_MCP_SITE_ALIAS="Site alias" -COM_JOOMENGINE_MCP_SYNCHRONIZE_JCB="Refresh JCB definitions" -COM_JOOMENGINE_MCP_SYNCHRONIZE_JCB_DESC="Inspect the installed JCB routes and commands and update owned catalogue definitions while preserving administrator customizations." +COM_JOOMENGINE_MCP_SYNCHRONIZE_JCB="Refresh component API and JCB definitions" +COM_JOOMENGINE_MCP_SYNCHRONIZE_JCB_DESC="Inspect registered APIs of installed components and JCB commands, then update owned catalogue definitions while preserving administrator customizations. Native administration permission is required for each selected component." COM_JOOMENGINE_MCP_TARGETS="Targets" COM_JOOMENGINE_MCP_TARGET_EDIT="Edit target" COM_JOOMENGINE_MCP_TARGET_NEW="New target" diff --git a/admin/language/en-GB/com_joomengine_mcp.sys.ini b/admin/language/en-GB/com_joomengine_mcp.sys.ini index cd7c63c..e7c3e59 100644 --- a/admin/language/en-GB/com_joomengine_mcp.sys.ini +++ b/admin/language/en-GB/com_joomengine_mcp.sys.ini @@ -81,7 +81,7 @@ COM_JOOMENGINE_MCP_FIELD_VERSION="Revision" COM_JOOMENGINE_MCP_GRANTS="Permission grants" COM_JOOMENGINE_MCP_IDENTIFIER_HELP="Stable unique identifier. Renaming a shipped definition changes its public protocol identity." COM_JOOMENGINE_MCP_INVALID_STATE="The requested publication state is invalid." -COM_JOOMENGINE_MCP_JCB_SYNCHRONIZED="The installed JCB catalogue definitions were refreshed." +COM_JOOMENGINE_MCP_JCB_SYNCHRONIZED="The installed component API and JCB command definitions were refreshed." COM_JOOMENGINE_MCP_JOBS="Jobs" COM_JOOMENGINE_MCP_JOB_CANCELLATION_DESC="Queued cancellation prevents the operation from starting. Running cancellation asks its worker to stop; it does not roll back changes. Review uncertain or partial effects in Executions." COM_JOOMENGINE_MCP_JOB_CANCELLATION_REQUESTED="The job cancellation request was recorded." @@ -177,8 +177,8 @@ COM_JOOMENGINE_MCP_SCHEMA_N_ITEMS_UNPUBLISHED_1="Definition unpublished." COM_JOOMENGINE_MCP_SESSION_TTL="Protocol session lifetime (seconds)" COM_JOOMENGINE_MCP_SHIPPED="Shipped definition" COM_JOOMENGINE_MCP_SITE_ALIAS="Site alias" -COM_JOOMENGINE_MCP_SYNCHRONIZE_JCB="Refresh JCB definitions" -COM_JOOMENGINE_MCP_SYNCHRONIZE_JCB_DESC="Inspect the installed JCB routes and commands and update owned catalogue definitions while preserving administrator customizations." +COM_JOOMENGINE_MCP_SYNCHRONIZE_JCB="Refresh component API and JCB definitions" +COM_JOOMENGINE_MCP_SYNCHRONIZE_JCB_DESC="Inspect registered APIs of installed components and JCB commands, then update owned catalogue definitions while preserving administrator customizations. Native administration permission is required for each selected component." COM_JOOMENGINE_MCP_TARGETS="Targets" COM_JOOMENGINE_MCP_TARGET_EDIT="Edit target" COM_JOOMENGINE_MCP_TARGET_NEW="New target" diff --git a/admin/sql/install.mysql.utf8.sql b/admin/sql/install.mysql.utf8.sql index 7e36a1e..ba2aa62 100644 --- a/admin/sql/install.mysql.utf8.sql +++ b/admin/sql/install.mysql.utf8.sql @@ -463,12 +463,14 @@ INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `d INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (405, 1, 'schema.94e14f6a626475276e8ad8e6a53554fda281b3a308afae523a28eafa9165aaa1', 'Schema 94e14f6a6264', '{"type":"object","properties":{"site":{"description":"Configured site alias; omit for the default site.","type":"string","minLength":1},"id":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idempotencyKey":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"etag":{"type":"string","maxLength":512},"data":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":255},"catid":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"alias":{"type":"string","maxLength":400},"articletext":{"type":"string","maxLength":5000000},"introtext":{"type":"string","maxLength":2000000},"fulltext":{"type":"string","maxLength":3000000},"state":{"type":"integer","minimum":-2,"maximum":1},"access":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"featured":{"anyOf":[{"type":"boolean"},{"type":"integer","minimum":0,"maximum":1}]},"language":{"type":"string","maxLength":50},"metadesc":{"type":"string","maxLength":1000},"metakey":{"type":"string","maxLength":1000},"publish_up":{"anyOf":[{"type":"string","format":"date-time","pattern":"^(?:(?:\\\\d\\\\d[2468][048]|\\\\d\\\\d[13579][26]|\\\\d\\\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\\\d|30)|(?:02)-(?:0[1-9]|1\\\\d|2[0-8])))T(?:(?:[01]\\\\d|2[0-3]):[0-5]\\\\d(?::[0-5]\\\\d(?:\\\\.\\\\d+)?)?(?:Z|))$"},{"type":"null"}]},"publish_down":{"anyOf":[{"type":"string","format":"date-time","pattern":"^(?:(?:\\\\d\\\\d[2468][048]|\\\\d\\\\d[13579][26]|\\\\d\\\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\\\d|30)|(?:02)-(?:0[1-9]|1\\\\d|2[0-8])))T(?:(?:[01]\\\\d|2[0-3]):[0-5]\\\\d(?::[0-5]\\\\d(?:\\\\.\\\\d+)?)?(?:Z|))$"},{"type":"null"}]}}}},"required":["id","idempotencyKey","data"],"$schema":"http://json-schema.org/draft-07/schema#"}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '0ee0d356b6eaba5de77c6570acf1993ae48e7707ecd30161df73f4d49972792e', 0); INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (406, 1, 'schema.799f5bd3bf92211ee95992a5ae189930ba47bae76ea7ffc3abfa1510cd6dddc5', 'Schema 799f5bd3bf92', '{"type":"object","properties":{"site":{"description":"Configured site alias; omit for the default site.","type":"string","minLength":1},"id":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idempotencyKey":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"etag":{"type":"string","maxLength":512}},"required":["id","idempotencyKey"],"$schema":"http://json-schema.org/draft-07/schema#"}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '400759d3dc2f7dca0bfcd7d962654f1f997d1611830c24e491f8cb85c2f94559', 0); INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (407, 1, 'schema.9b11202cb0cc4d613d538e23fdfe31fa8f6310d26c066637356a1e41370825d9', 'Schema 9b11202cb0cc', '{"type":"object","properties":{"confirmationToken":{"type":"string","minLength":64,"maxLength":4096}},"required":["confirmationToken"],"$schema":"http://json-schema.org/draft-07/schema#"}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '3dccae787bae85cb572d24eee9ac1591df14a6dfdccdaa3098e24416c383a4d0', 0); -INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (408, 1, 'schema.15fb5389bc29b0c83f4bf179870c7f89775fbe0da8e1d24f43af140b462d2049', 'Schema 15fb5389bc29', '{"type":"object","properties":{"site":{"description":"Configured site alias; omit for the default site.","type":"string","minLength":1},"toolsets":{"description":"Enabled write toolset names from this server''s action catalogue. Each scope is checked against the current Joomla identity.","type":"array","minItems":1,"maxItems":64,"items":{"type":"string","minLength":1,"maxLength":190}},"duration":{"type":"string","enum":["once","30-minutes","indefinite"]},"reason":{"type":"string","minLength":3,"maxLength":500}},"required":["toolsets","duration","reason"],"$schema":"http://json-schema.org/draft-07/schema#"}', '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '258abfe5e76373a6e695627204c4851519146b821d9d1396a3528b7b94a0b1db', 0); -INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (409, 1, 'schema.37cde43b5df6f7f600f1f4410f2a4706d53e935460124c699cab61e68f36aee3', 'Schema 37cde43b5df6', '{"type":"object","properties":{"data":{"type":"object","minProperties":1,"maxProperties":512,"properties":{"title":{"description":"Reviewed Joomla site module form field."},"note":{"description":"Reviewed Joomla site module form field."},"content":{"description":"Reviewed Joomla site module form field."},"ordering":{"type":"integer","description":"Use zero or omit ordering to let Joomla assign the next order for this module position. A nonzero ordering requests that exact value."},"position":{"description":"Reviewed Joomla site module form field."},"published":{"description":"Reviewed Joomla site module form field."},"module":{"description":"Reviewed Joomla site module form field."},"access":{"description":"Reviewed Joomla site module form field."},"showtitle":{"description":"Reviewed Joomla site module form field."},"params":{"description":"Reviewed Joomla site module form field."},"language":{"description":"Reviewed Joomla site module form field."},"assigned":{"description":"Reviewed Joomla site module form field."}},"additionalProperties":false,"description":"Allowlisted Joomla form JSON. Joomla validates resource-specific values and ACL."}},"required":["data"],"additionalProperties":false}', '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '5a8e7737e17af89a3a12b449cef973fa75d0958e53afaa6555b108de27c8c38a', 0); -INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (410, 1, 'schema.edd0e2e7b8da488986a2b08ed09766131403db7f5719abb2a007401f8b6df409', 'Schema edd0e2e7b8da', '{"type":"object","properties":{"data":{"type":"object","minProperties":1,"maxProperties":512,"properties":{"title":{"description":"Reviewed Joomla administrator module form field."},"note":{"description":"Reviewed Joomla administrator module form field."},"content":{"description":"Reviewed Joomla administrator module form field."},"ordering":{"type":"integer","description":"Use zero or omit ordering to let Joomla assign the next order for this module position. A nonzero ordering requests that exact value."},"position":{"description":"Reviewed Joomla administrator module form field."},"published":{"description":"Reviewed Joomla administrator module form field."},"module":{"description":"Reviewed Joomla administrator module form field."},"access":{"description":"Reviewed Joomla administrator module form field."},"showtitle":{"description":"Reviewed Joomla administrator module form field."},"params":{"description":"Reviewed Joomla administrator module form field."},"language":{"description":"Reviewed Joomla administrator module form field."},"assigned":{"description":"Reviewed Joomla administrator module form field."}},"additionalProperties":false,"description":"Allowlisted Joomla form JSON. Joomla validates resource-specific values and ACL."}},"required":["data"],"additionalProperties":false}', '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'e9c49ed54ceee7a24fb9143a6269741a460ea23f2f3351505fa8742ddbf47699', 0); -INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (411, 1, 'schema.514ff59fe2ec7de14b1a0bb8dce0754164a69ca1e795605c6afc13cfd2b63159', 'Schema 514ff59fe2ec', '{"type":"object","properties":{"site":{"type":"string","minLength":1,"maxLength":190,"description":"Configured installation alias; omit to use this server."},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"offset":{"type":"integer","minimum":0,"maximum":1000000,"default":0}},"required":[],"additionalProperties":false}', '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'a43985614cf0194fb0d8e3e03b99a8960326e489c9973d1f878fe776a2958440', 0); -INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (412, 1, 'schema.33d5e81d0321fc7ccb4e3210a6b2e80afa0ddc60d2e7307bc47f02f46330ec4a', 'Schema 33d5e81d0321', '{"type":"object","properties":{"site":{"type":"string","minLength":1,"maxLength":190,"description":"Configured installation alias; omit to use this server."},"jobId":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$","description":"Opaque identifier returned by this server for the authenticated principal."}},"required":["jobId"],"additionalProperties":false}', '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'eabccb8d39b47526cae04180f447781c76c2c8b06256cbf34510022742a71238', 0); -INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (413, 1, 'schema.40995628f009ab15cecc1a0ca487ca74c7577b0331d36ab1e4eeeeca9de5460b', 'Schema 40995628f009', '{"type":"object","properties":{"site":{"type":"string","minLength":1,"maxLength":190,"description":"Configured installation alias; omit to use this server."},"artifactId":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$","description":"Opaque identifier returned by this server for the authenticated principal."},"offset":{"type":"integer","minimum":0,"default":0},"length":{"type":"integer","minimum":1,"maximum":262144,"default":65536}},"required":["artifactId"],"additionalProperties":false}', '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '4ad1570a752bb38ca99d045c8fffa66ceec7961185214c138303709dc579f20e', 0); +INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (408, 1, 'schema.15fb5389bc29b0c83f4bf179870c7f89775fbe0da8e1d24f43af140b462d2049', 'Schema 15fb5389bc29', '{"type":"object","properties":{"site":{"description":"Configured site alias; omit for the default site.","type":"string","minLength":1},"toolsets":{"description":"Enabled write toolset names from this server''s action catalogue. Each scope is checked against the current Joomla identity.","type":"array","minItems":1,"maxItems":64,"items":{"type":"string","minLength":1,"maxLength":190}},"duration":{"type":"string","enum":["once","30-minutes","indefinite"]},"reason":{"type":"string","minLength":3,"maxLength":500}},"required":["toolsets","duration","reason"],"$schema":"http://json-schema.org/draft-07/schema#"}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '258abfe5e76373a6e695627204c4851519146b821d9d1396a3528b7b94a0b1db', 0); +INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (409, 1, 'schema.c36a04dc5114ed333c8e8d846cdddd9b21f3b50075bcecb0e951df2853e55f7f', 'Schema c36a04dc5114', '{"type":"object","properties":{"site":{"description":"Configured site alias; omit for the default site.","type":"string","minLength":1},"action":{"type":"string","minLength":3,"maxLength":160},"input":{"description":"Bounded JSON argument object validated again against the selected action schema. Nested objects and arrays retain their JSON types.","default":{},"type":"object","maxProperties":512,"propertyNames":{"type":"string"},"additionalProperties":{}},"transport":{"default":"auto","type":"string","enum":["auto","api","cli"]}},"required":["action"],"$schema":"http://json-schema.org/draft-07/schema#"}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'c4f360e0818319706398fe921e3cd4bdb05475c258db491200573ed1031db7c9', 0); +INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (410, 1, 'schema.d5e18a6816a315862f4898d7eb178d8ab5754f88a1a9168cb3f3b7ac155d250b', 'Schema d5e18a6816a3', '{"type":"object","properties":{"site":{"description":"Configured site alias; omit for the default site.","type":"string","minLength":1},"action":{"type":"string","minLength":3,"maxLength":160},"input":{"description":"Bounded JSON argument object validated again against the selected native action schema. Nested objects and arrays retain their JSON types.","default":{},"type":"object","maxProperties":512,"propertyNames":{"type":"string"},"additionalProperties":{}}},"required":["action"],"$schema":"http://json-schema.org/draft-07/schema#"}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '19e026f56233b39a274c80f259062dab39ee425d7143c3f030e58b9a54164a7f', 0); +INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (411, 1, 'schema.37cde43b5df6f7f600f1f4410f2a4706d53e935460124c699cab61e68f36aee3', 'Schema 37cde43b5df6', '{"type":"object","properties":{"data":{"type":"object","minProperties":1,"maxProperties":512,"properties":{"title":{"description":"Reviewed Joomla site module form field."},"note":{"description":"Reviewed Joomla site module form field."},"content":{"description":"Reviewed Joomla site module form field."},"ordering":{"type":"integer","description":"Use zero or omit ordering to let Joomla assign the next order for this module position. A nonzero ordering requests that exact value."},"position":{"description":"Reviewed Joomla site module form field."},"published":{"description":"Reviewed Joomla site module form field."},"module":{"description":"Reviewed Joomla site module form field."},"access":{"description":"Reviewed Joomla site module form field."},"showtitle":{"description":"Reviewed Joomla site module form field."},"params":{"description":"Reviewed Joomla site module form field."},"language":{"description":"Reviewed Joomla site module form field."},"assigned":{"description":"Reviewed Joomla site module form field."}},"additionalProperties":false,"description":"Allowlisted Joomla form JSON. Joomla validates resource-specific values and ACL."}},"required":["data"],"additionalProperties":false}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '5a8e7737e17af89a3a12b449cef973fa75d0958e53afaa6555b108de27c8c38a', 0); +INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (412, 1, 'schema.edd0e2e7b8da488986a2b08ed09766131403db7f5719abb2a007401f8b6df409', 'Schema edd0e2e7b8da', '{"type":"object","properties":{"data":{"type":"object","minProperties":1,"maxProperties":512,"properties":{"title":{"description":"Reviewed Joomla administrator module form field."},"note":{"description":"Reviewed Joomla administrator module form field."},"content":{"description":"Reviewed Joomla administrator module form field."},"ordering":{"type":"integer","description":"Use zero or omit ordering to let Joomla assign the next order for this module position. A nonzero ordering requests that exact value."},"position":{"description":"Reviewed Joomla administrator module form field."},"published":{"description":"Reviewed Joomla administrator module form field."},"module":{"description":"Reviewed Joomla administrator module form field."},"access":{"description":"Reviewed Joomla administrator module form field."},"showtitle":{"description":"Reviewed Joomla administrator module form field."},"params":{"description":"Reviewed Joomla administrator module form field."},"language":{"description":"Reviewed Joomla administrator module form field."},"assigned":{"description":"Reviewed Joomla administrator module form field."}},"additionalProperties":false,"description":"Allowlisted Joomla form JSON. Joomla validates resource-specific values and ACL."}},"required":["data"],"additionalProperties":false}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'e9c49ed54ceee7a24fb9143a6269741a460ea23f2f3351505fa8742ddbf47699', 0); +INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (413, 1, 'schema.514ff59fe2ec7de14b1a0bb8dce0754164a69ca1e795605c6afc13cfd2b63159', 'Schema 514ff59fe2ec', '{"type":"object","properties":{"site":{"type":"string","minLength":1,"maxLength":190,"description":"Configured installation alias; omit to use this server."},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"offset":{"type":"integer","minimum":0,"maximum":1000000,"default":0}},"required":[],"additionalProperties":false}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'a43985614cf0194fb0d8e3e03b99a8960326e489c9973d1f878fe776a2958440', 0); +INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (414, 1, 'schema.33d5e81d0321fc7ccb4e3210a6b2e80afa0ddc60d2e7307bc47f02f46330ec4a', 'Schema 33d5e81d0321', '{"type":"object","properties":{"site":{"type":"string","minLength":1,"maxLength":190,"description":"Configured installation alias; omit to use this server."},"jobId":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$","description":"Opaque identifier returned by this server for the authenticated principal."}},"required":["jobId"],"additionalProperties":false}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'eabccb8d39b47526cae04180f447781c76c2c8b06256cbf34510022742a71238', 0); +INSERT INTO `#__joomengine_mcp_schema` (`id`, `provider_id`, `name`, `title`, `document`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (415, 1, 'schema.40995628f009ab15cecc1a0ca487ca74c7577b0331d36ab1e4eeeeca9de5460b', 'Schema 40995628f009', '{"type":"object","properties":{"site":{"type":"string","minLength":1,"maxLength":190,"description":"Configured installation alias; omit to use this server."},"artifactId":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$","description":"Opaque identifier returned by this server for the authenticated principal."},"offset":{"type":"integer","minimum":0,"default":0},"length":{"type":"integer","minimum":1,"maximum":262144,"default":65536}},"required":["artifactId"],"additionalProperties":false}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '4ad1570a752bb38ca99d045c8fffa66ceec7961185214c138303709dc579f20e', 0); CREATE TABLE IF NOT EXISTS `#__joomengine_mcp_action` ( `id` INT NOT NULL AUTO_INCREMENT, @@ -709,7 +711,7 @@ INSERT INTO `#__joomengine_mcp_action` (`id`, `provider_id`, `name`, `title`, `d INSERT INTO `#__joomengine_mcp_action` (`id`, `provider_id`, `name`, `title`, `description`, `domain`, `toolset`, `effect`, `risk`, `input_schema_id`, `output_schema_id`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (200, 1, 'menus.site.update', 'Update Site menu', 'Updates site menu through Joomla''s menus API controller.', 'menus', 'structure.write', 'write', 'write', 262, 1, '{"id":"menus.site.update","title":"Update Site menu","description":"Updates site menu through Joomla''s menus API controller.","domain":"menus","operation":"update","method":"PATCH","routeTemplate":"v1/menus/site/:id","routeParameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"bodyPolicy":"required","toolset":"structure.write","risk":"write","acl":{"apiLogin":"core.login.api","component":"com_menus","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/menus","responseShape":"json-api","mutationBody":"flat-joomla-form-json"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/menus/src/Extension/Menus.php","registration":"createCRUDRoutes"},"sourceGate":null}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'a53852207a3ede85f78086b126afd637cd86119ded234c2ea593378363352bc3', 0); INSERT INTO `#__joomengine_mcp_action` (`id`, `provider_id`, `name`, `title`, `description`, `domain`, `toolset`, `effect`, `risk`, `input_schema_id`, `output_schema_id`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (201, 1, 'messages.messages.create', 'Create Private message', 'Creates private message through Joomla''s messages API controller.', 'messages', 'users.admin', 'write', 'write', 264, 1, '{"id":"messages.messages.create","title":"Create Private message","description":"Creates private message through Joomla''s messages API controller.","domain":"messages","operation":"create","method":"POST","routeTemplate":"v1/messages","routeParameters":[],"bodyPolicy":"required","toolset":"users.admin","risk":"write","acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/messages","responseShape":"json-api","mutationBody":"flat-joomla-form-json"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"sourceGate":null}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '71700e219f56c0534e78dd53f3ab7151ce13f6fe9ca48718a891dfda8a354ffd', 0); INSERT INTO `#__joomengine_mcp_action` (`id`, `provider_id`, `name`, `title`, `description`, `domain`, `toolset`, `effect`, `risk`, `input_schema_id`, `output_schema_id`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (202, 1, 'messages.messages.delete', 'Delete Private message', 'Deletes private message through Joomla''s messages API controller.', 'messages', 'users.admin', 'write', 'destructive', 7, 1, '{"id":"messages.messages.delete","title":"Delete Private message","description":"Deletes private message through Joomla''s messages API controller.","domain":"messages","operation":"delete","method":"DELETE","routeTemplate":"v1/messages/:id","routeParameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"bodyPolicy":"none","toolset":"users.admin","risk":"destructive","acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/messages","responseShape":"json-api","mutationBody":"flat-joomla-form-json"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"sourceGate":null}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '6e873d11bf228290af8f2cbee199863e621dd4444dc6afe0b273997ef7294710', 0); -INSERT INTO `#__joomengine_mcp_action` (`id`, `provider_id`, `name`, `title`, `description`, `domain`, `toolset`, `effect`, `risk`, `input_schema_id`, `output_schema_id`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (203, 1, 'messages.messages.get', 'Get Private message', 'Gets one private message by numeric identifier through Joomla''s native API. The native item GET marks the stored message read and may return the representation loaded before that change. Eligible local, uncustomized write planning uses a separately declared recipient-scoped native-owned-record snapshot without marking the message read.', 'messages', 'users.read', 'read', 'sensitive-read', 10, 1, '{"id":"messages.messages.get","title":"Get Private message","description":"Gets one private message by numeric identifier through Joomla''s native API. The native item GET marks the stored message read and may return the representation loaded before that change. Eligible local, uncustomized write planning uses a separately declared recipient-scoped native-owned-record snapshot without marking the message read.","domain":"messages","operation":"get","method":"GET","routeTemplate":"v1/messages/:id","routeParameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"toolset":"users.read","risk":"sensitive-read","sideEffect":true,"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/messages","responseShape":"json-api","mutationBody":"not-applicable"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"sourceGate":null}', '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '0fc426670f60649455c397d62c4579b4970e62317ec3e6f3d5072cb46e43ec86', 0); +INSERT INTO `#__joomengine_mcp_action` (`id`, `provider_id`, `name`, `title`, `description`, `domain`, `toolset`, `effect`, `risk`, `input_schema_id`, `output_schema_id`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (203, 1, 'messages.messages.get', 'Get Private message', 'Gets one private message by numeric identifier through Joomla''s native API. The native item GET marks the stored message read and may return the representation loaded before that change. Eligible local, uncustomized write planning uses a separately declared recipient-scoped native-owned-record snapshot without marking the message read.', 'messages', 'users.read', 'read', 'sensitive-read', 10, 1, '{"id":"messages.messages.get","title":"Get Private message","description":"Gets one private message by numeric identifier through Joomla''s native API. The native item GET marks the stored message read and may return the representation loaded before that change. Eligible local, uncustomized write planning uses a separately declared recipient-scoped native-owned-record snapshot without marking the message read.","domain":"messages","operation":"get","method":"GET","routeTemplate":"v1/messages/:id","routeParameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"toolset":"users.read","risk":"sensitive-read","sideEffect":true,"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/messages","responseShape":"json-api","mutationBody":"not-applicable"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"sourceGate":null}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '0fc426670f60649455c397d62c4579b4970e62317ec3e6f3d5072cb46e43ec86', 0); INSERT INTO `#__joomengine_mcp_action` (`id`, `provider_id`, `name`, `title`, `description`, `domain`, `toolset`, `effect`, `risk`, `input_schema_id`, `output_schema_id`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (204, 1, 'messages.messages.list', 'List Private messages', 'Lists private messages through Joomla''s messages API controller.', 'messages', 'users.read', 'read', 'sensitive-read', 13, 1, '{"id":"messages.messages.list","title":"List Private messages","description":"Lists private messages through Joomla''s messages API controller.","domain":"messages","operation":"list","method":"GET","routeTemplate":"v1/messages","routeParameters":[],"paginated":true,"toolset":"users.read","risk":"sensitive-read","sideEffect":false,"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/messages","responseShape":"json-api","mutationBody":"not-applicable"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"sourceGate":null}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '4f37ea2f2703399fb7de040e4be5be37834956d97215610d951d144bee4bc8ec', 0); INSERT INTO `#__joomengine_mcp_action` (`id`, `provider_id`, `name`, `title`, `description`, `domain`, `toolset`, `effect`, `risk`, `input_schema_id`, `output_schema_id`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (205, 1, 'messages.messages.update', 'Update Private message', 'Updates private message through Joomla''s messages API controller.', 'messages', 'users.admin', 'write', 'write', 271, 1, '{"id":"messages.messages.update","title":"Update Private message","description":"Updates private message through Joomla''s messages API controller.","domain":"messages","operation":"update","method":"PATCH","routeTemplate":"v1/messages/:id","routeParameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"bodyPolicy":"required","toolset":"users.admin","risk":"write","acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/messages","responseShape":"json-api","mutationBody":"flat-joomla-form-json"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"sourceGate":null}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'e3ab358bd780e3f5618b14af7b8a8d5eee90b1e50d79f77eda87f1cd5a6af41e', 0); INSERT INTO `#__joomengine_mcp_action` (`id`, `provider_id`, `name`, `title`, `description`, `domain`, `toolset`, `effect`, `risk`, `input_schema_id`, `output_schema_id`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (206, 1, 'modules.administrator-types.list', 'List administrator module types', 'Lists available module types for the administrator client.', 'modules', 'structure.read', 'read', 'read', 49, 1, '{"id":"modules.administrator-types.list","title":"List administrator module types","description":"Lists available module types for the administrator client.","domain":"modules","operation":"list","method":"GET","routeTemplate":"v1/modules/types/administrator","routeParameters":[],"paginated":false,"toolset":"structure.read","risk":"read","sideEffect":false,"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/modules","responseShape":"json-api","mutationBody":"not-applicable"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"Route"},"sourceGate":null}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '15cfcbe9da6d6fc27df46893eeac6655f2df7ed6ea1dcf3c7eb8c1fa9e394d8d', 0); @@ -1155,16 +1157,16 @@ INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `params`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `seed_revision`, `seed_hash`, `customized`) VALUES (322, 1, 200, 'menus.site.update.cli', 'menus.site.update (CLI)', 'cli', 'native.core-entity', 247, 263, '{"entity":{"id":"menus.site","label":"site menus","component":"com_menus","listModel":"Menus","itemModel":"Menu","readFields":["id","menutype","title","description","client_id"],"writeFields":["menutype","title","description"],"defaults":{"client_id":0},"modelState":{"client_id":0},"stateFilter":"filter.published","supportsState":false,"highRisk":false,"sensitiveFields":[],"primaryKey":"id","stateField":"published"},"operation":"update"}', '{"name":"menus.site.update","description":"Update site menus through fixed Joomla administrator models.","risk":"write","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_menus"},{"action":"core.edit","asset":"com_menus"}],"required_extensions":["com_menus"]}', '{"verification":{"read_action":"menus.site.get","operation":"update","primary_key":"id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '2cff50f4f6b440da3c684f9995a77efad32e1a36', '1146cb835098bc57edcb619ff82733db3bf3a423f54e172cf3a3ba45726753c3', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (323, 1, 201, 'messages.messages.create.api', 'messages.messages.create (API)', 'api', 'api.request', 264, 1, '{"method":"POST","route":"/v1/messages","route_parameters":[],"paginated":false,"body_policy":"required","operation":"create","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"messages.messages.get","mutation_rule":null}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'ad693eb44ffb7270ca8dc74febd45c129044b360a8e6422261c587a0ee96838a', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `params`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `seed_revision`, `seed_hash`, `customized`) VALUES (324, 1, 201, 'messages.messages.create.cli', 'messages.messages.create (CLI)', 'cli', 'native.core-entity', 265, 266, '{"entity":{"id":"messages.messages","label":"private messages","component":"com_messages","listModel":"Messages","itemModel":"Message","readFields":["message_id","id","user_id_from","user_id_to","folder_id","date_time","state","priority","subject","message"],"writeFields":["user_id_to","folder_id","state","priority","subject","message"],"defaults":[],"modelState":[],"stateFilter":"filter.state","supportsState":false,"highRisk":true,"sensitiveFields":[],"primaryKey":"message_id","stateField":"published"},"operation":"create"}', '{"name":"messages.messages.create","description":"Create private messages through fixed Joomla administrator models.","risk":"high","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_messages"},{"action":"core.create","asset":"com_messages"}],"required_extensions":["com_messages"]}', '{"verification":{"read_action":"messages.messages.get","operation":"create","primary_key":"message_id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '2cff50f4f6b440da3c684f9995a77efad32e1a36', '47496b56f6a571e6121908ce119888930d0243468551d46614e773e26503ab66', 0); -INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `seed_revision`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (325, 1, 202, 'messages.messages.delete.api', 'messages.messages.delete (API)', 'api', 'api.request', 7, 1, '{"method":"DELETE","route":"/v1/messages/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"none","operation":"delete","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"messages.messages.get","mutation_rule":null,"snapshot_contract":"joomla.message-owned-record.v1"}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '61b7441efdda777437368d611fc39f2a3c44e7a8ee218c2fe9dfe31cf97d8096', 0); +INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `seed_revision`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (325, 1, 202, 'messages.messages.delete.api', 'messages.messages.delete (API)', 'api', 'api.request', 7, 1, '{"method":"DELETE","route":"/v1/messages/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"none","operation":"delete","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"messages.messages.get","mutation_rule":null,"snapshot_contract":"joomla.message-owned-record.v1"}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '61b7441efdda777437368d611fc39f2a3c44e7a8ee218c2fe9dfe31cf97d8096', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `params`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `seed_revision`, `seed_hash`, `customized`) VALUES (326, 1, 202, 'messages.messages.delete.cli', 'messages.messages.delete (CLI)', 'cli', 'native.core-entity', 8, 267, '{"entity":{"id":"messages.messages","label":"private messages","component":"com_messages","listModel":"Messages","itemModel":"Message","readFields":["message_id","id","user_id_from","user_id_to","folder_id","date_time","state","priority","subject","message"],"writeFields":["user_id_to","folder_id","state","priority","subject","message"],"defaults":[],"modelState":[],"stateFilter":"filter.state","supportsState":false,"highRisk":true,"sensitiveFields":[],"primaryKey":"message_id","stateField":"published"},"operation":"delete"}', '{"name":"messages.messages.delete","description":"Delete private messages through fixed Joomla administrator models.","risk":"high","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_messages"},{"action":"core.delete","asset":"com_messages"}],"required_extensions":["com_messages"]}', '{"verification":{"read_action":"messages.messages.get","operation":"delete","primary_key":"message_id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '2cff50f4f6b440da3c684f9995a77efad32e1a36', '096879141fde89cd33aab15e0e8bb52d80dba481018c436bc95c7639ead48773', 0); -INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `seed_revision`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (327, 1, 203, 'messages.messages.get.api', 'messages.messages.get (API)', 'api', 'api.request', 10, 1, '{"method":"GET","route":"/v1/messages/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"none","operation":"get","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"mutation_rule":null,"snapshot_contract":"joomla.message-owned-record.v1"}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'a47e2a59081678b40d797a494e41aabf9a574efe4e4722d9625534c2374faf4b', 0); +INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `seed_revision`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (327, 1, 203, 'messages.messages.get.api', 'messages.messages.get (API)', 'api', 'api.request', 10, 1, '{"method":"GET","route":"/v1/messages/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"none","operation":"get","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"mutation_rule":null,"snapshot_contract":"joomla.message-owned-record.v1"}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'a47e2a59081678b40d797a494e41aabf9a574efe4e4722d9625534c2374faf4b', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `params`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `seed_revision`, `seed_hash`, `customized`) VALUES (328, 1, 203, 'messages.messages.get.cli', 'messages.messages.get (CLI)', 'cli', 'native.core-entity', 11, 268, '{"entity":{"id":"messages.messages","label":"private messages","component":"com_messages","listModel":"Messages","itemModel":"Message","readFields":["message_id","id","user_id_from","user_id_to","folder_id","date_time","state","priority","subject","message"],"writeFields":["user_id_to","folder_id","state","priority","subject","message"],"defaults":[],"modelState":[],"stateFilter":"filter.state","supportsState":false,"highRisk":true,"sensitiveFields":[],"primaryKey":"message_id","stateField":"published"},"operation":"get"}', '{"name":"messages.messages.get","description":"Get private messages through fixed Joomla administrator models.","risk":"read","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_messages"}],"required_extensions":["com_messages"]}', '{"verification":{}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '2cff50f4f6b440da3c684f9995a77efad32e1a36', '99c11444ab62ca91bcf6c8189cbd8bbf79a4646be4cee6d0e59a53e52370193e', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (329, 1, 204, 'messages.messages.list.api', 'messages.messages.list (API)', 'api', 'api.request', 13, 1, '{"method":"GET","route":"/v1/messages","route_parameters":[],"paginated":true,"body_policy":"none","operation":"list","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"mutation_rule":null}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '084d004ed5e528678dfc099f607fb5243a1864b375ca126bb3394d26da98d658', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `params`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `seed_revision`, `seed_hash`, `customized`) VALUES (330, 1, 204, 'messages.messages.list.cli', 'messages.messages.list (CLI)', 'cli', 'native.core-entity', 269, 270, '{"entity":{"id":"messages.messages","label":"private messages","component":"com_messages","listModel":"Messages","itemModel":"Message","readFields":["message_id","id","user_id_from","user_id_to","folder_id","date_time","state","priority","subject","message"],"writeFields":["user_id_to","folder_id","state","priority","subject","message"],"defaults":[],"modelState":[],"stateFilter":"filter.state","supportsState":false,"highRisk":true,"sensitiveFields":[],"primaryKey":"message_id","stateField":"published"},"operation":"list"}', '{"name":"messages.messages.list","description":"List private messages through fixed Joomla administrator models.","risk":"read","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_messages"}],"required_extensions":["com_messages"]}', '{"verification":{}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '2cff50f4f6b440da3c684f9995a77efad32e1a36', '1da3e1e5182879d63c461fe4c99551f4cf71771fdc251416f26d4f4249925d9a', 0); -INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `seed_revision`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (331, 1, 205, 'messages.messages.update.api', 'messages.messages.update (API)', 'api', 'api.request', 271, 1, '{"method":"PATCH","route":"/v1/messages/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"required","operation":"update","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"messages.messages.get","mutation_rule":null,"snapshot_contract":"joomla.message-owned-record.v1"}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '80a71ce429cbfc6954f8d67a293a4a728f22acdac4174840eeaad474282f6813', 0); +INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `seed_revision`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (331, 1, 205, 'messages.messages.update.api', 'messages.messages.update (API)', 'api', 'api.request', 271, 1, '{"method":"PATCH","route":"/v1/messages/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"required","operation":"update","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"messages.messages.get","mutation_rule":null,"snapshot_contract":"joomla.message-owned-record.v1"}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '80a71ce429cbfc6954f8d67a293a4a728f22acdac4174840eeaad474282f6813', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `params`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `seed_revision`, `seed_hash`, `customized`) VALUES (332, 1, 205, 'messages.messages.update.cli', 'messages.messages.update (CLI)', 'cli', 'native.core-entity', 272, 273, '{"entity":{"id":"messages.messages","label":"private messages","component":"com_messages","listModel":"Messages","itemModel":"Message","readFields":["message_id","id","user_id_from","user_id_to","folder_id","date_time","state","priority","subject","message"],"writeFields":["user_id_to","folder_id","state","priority","subject","message"],"defaults":[],"modelState":[],"stateFilter":"filter.state","supportsState":false,"highRisk":true,"sensitiveFields":[],"primaryKey":"message_id","stateField":"published"},"operation":"update"}', '{"name":"messages.messages.update","description":"Update private messages through fixed Joomla administrator models.","risk":"high","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_messages"},{"action":"core.edit","asset":"com_messages"}],"required_extensions":["com_messages"]}', '{"verification":{"read_action":"messages.messages.get","operation":"update","primary_key":"message_id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'f5acac62fc1103a275e40c3217cecb5437dc2c33f7d85fdbb1abbc0a58f5803c', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (333, 1, 206, 'modules.administrator-types.list.api', 'modules.administrator-types.list (API)', 'api', 'api.request', 49, 1, '{"method":"GET","route":"/v1/modules/types/administrator","route_parameters":[],"paginated":false,"body_policy":"none","operation":"list","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"mutation_rule":null}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"Route"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'a245af06b145c70c39953d62cac6fa49e0b131db9ee2af1793d45f05c5bb3e08', 0); -INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `seed_revision`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (334, 1, 207, 'modules.administrator.create.api', 'modules.administrator.create (API)', 'api', 'api.request', 410, 1, '{"method":"POST","route":"/v1/modules/administrator","route_parameters":[],"paginated":false,"body_policy":"required","operation":"create","body_defaults":{"client_id":1},"query_defaults":{},"preserve_fields":["params","assigned"],"derived_fields":["module_assignment"],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"modules.administrator.get","mutation_rule":null}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '20fb6d299eb279dac608c98c7e5a976805bb14f3cd51002b7da70314ea6c7c36', 0); +INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `seed_revision`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (334, 1, 207, 'modules.administrator.create.api', 'modules.administrator.create (API)', 'api', 'api.request', 412, 1, '{"method":"POST","route":"/v1/modules/administrator","route_parameters":[],"paginated":false,"body_policy":"required","operation":"create","body_defaults":{"client_id":1},"query_defaults":{},"preserve_fields":["params","assigned"],"derived_fields":["module_assignment"],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"modules.administrator.get","mutation_rule":null}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2217f95665684e293ee598882493725504b81d4724c8f864a797ad3775143fd8', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `params`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `seed_revision`, `seed_hash`, `customized`) VALUES (335, 1, 207, 'modules.administrator.create.cli', 'modules.administrator.create (CLI)', 'cli', 'native.core-entity', 275, 276, '{"entity":{"id":"modules.administrator","label":"administrator modules","component":"com_modules","listModel":"Modules","itemModel":"Module","readFields":["id","title","note","content","ordering","position","checked_out","published","module","access","showtitle","params","client_id","language","assigned"],"writeFields":["title","note","content","ordering","position","published","module","access","showtitle","params","language","assigned"],"defaults":{"client_id":1},"modelState":{"client_id":1},"stateFilter":"filter.published","supportsState":true,"highRisk":true,"sensitiveFields":[],"primaryKey":"id","stateField":"published"},"operation":"create"}', '{"name":"modules.administrator.create","description":"Create administrator modules through fixed Joomla administrator models.","risk":"high","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_modules"},{"action":"core.create","asset":"com_modules"}],"required_extensions":["com_modules"]}', '{"verification":{"read_action":"modules.administrator.get","operation":"create","primary_key":"id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'd2ad1fb04daa4ab39f3e1f71f00872aae4bcadf57e839d45f051dea11afdf035', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (336, 1, 208, 'modules.administrator.delete.api', 'modules.administrator.delete (API)', 'api', 'api.request', 7, 1, '{"method":"DELETE","route":"/v1/modules/administrator/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"none","operation":"delete","body_defaults":{"client_id":1},"query_defaults":{},"preserve_fields":["params","assigned"],"derived_fields":["module_assignment"],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"modules.administrator.get","mutation_rule":null}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'bff841b68a663aedbfc63ddf3f78ec4591ed1b063195eeef6203e403502ce8ce', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `params`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `seed_revision`, `seed_hash`, `customized`) VALUES (337, 1, 208, 'modules.administrator.delete.cli', 'modules.administrator.delete (CLI)', 'cli', 'native.core-entity', 8, 277, '{"entity":{"id":"modules.administrator","label":"administrator modules","component":"com_modules","listModel":"Modules","itemModel":"Module","readFields":["id","title","note","content","ordering","position","checked_out","published","module","access","showtitle","params","client_id","language","assigned"],"writeFields":["title","note","content","ordering","position","published","module","access","showtitle","params","language","assigned"],"defaults":{"client_id":1},"modelState":{"client_id":1},"stateFilter":"filter.published","supportsState":true,"highRisk":true,"sensitiveFields":[],"primaryKey":"id","stateField":"published"},"operation":"delete"}', '{"name":"modules.administrator.delete","description":"Delete administrator modules through fixed Joomla administrator models.","risk":"high","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_modules"},{"action":"core.delete","asset":"com_modules"}],"required_extensions":["com_modules"]}', '{"verification":{"read_action":"modules.administrator.get","operation":"delete","primary_key":"id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'b3cdcb2a48e7eed8d5297bb3446002af7597d15a95612264dfe28fff6c4a47ae', 0); @@ -1176,7 +1178,7 @@ INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (343, 1, 212, 'modules.administrator.update.api', 'modules.administrator.update (API)', 'api', 'api.request', 281, 1, '{"method":"PATCH","route":"/v1/modules/administrator/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"required","operation":"update","body_defaults":{"client_id":1},"query_defaults":{},"preserve_fields":["params","assigned"],"derived_fields":["module_assignment"],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"modules.administrator.get","mutation_rule":null}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'cd1a1805dd5dfd75de43db2f6e8f55991956696f0d28138550af9f899a683889', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `params`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `seed_revision`, `seed_hash`, `customized`) VALUES (344, 1, 212, 'modules.administrator.update.cli', 'modules.administrator.update (CLI)', 'cli', 'native.core-entity', 282, 283, '{"entity":{"id":"modules.administrator","label":"administrator modules","component":"com_modules","listModel":"Modules","itemModel":"Module","readFields":["id","title","note","content","ordering","position","checked_out","published","module","access","showtitle","params","client_id","language","assigned"],"writeFields":["title","note","content","ordering","position","published","module","access","showtitle","params","language","assigned"],"defaults":{"client_id":1},"modelState":{"client_id":1},"stateFilter":"filter.published","supportsState":true,"highRisk":true,"sensitiveFields":[],"primaryKey":"id","stateField":"published"},"operation":"update"}', '{"name":"modules.administrator.update","description":"Update administrator modules through fixed Joomla administrator models.","risk":"high","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_modules"},{"action":"core.edit","asset":"com_modules"}],"required_extensions":["com_modules"]}', '{"verification":{"read_action":"modules.administrator.get","operation":"update","primary_key":"id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '2cff50f4f6b440da3c684f9995a77efad32e1a36', '4bdea5e7fe478d2720be8c7e46cb219dbb647e9f0e734f73682eb64befd73353', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (345, 1, 213, 'modules.site-types.list.api', 'modules.site-types.list (API)', 'api', 'api.request', 49, 1, '{"method":"GET","route":"/v1/modules/types/site","route_parameters":[],"paginated":false,"body_policy":"none","operation":"list","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"mutation_rule":null}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"Route"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'c803328202d7306d91bc9b1763b6e7714b0a5a175c1660c3fa0ffdba9e69b567', 0); -INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `seed_revision`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (346, 1, 214, 'modules.site.create.api', 'modules.site.create (API)', 'api', 'api.request', 409, 1, '{"method":"POST","route":"/v1/modules/site","route_parameters":[],"paginated":false,"body_policy":"required","operation":"create","body_defaults":{"client_id":0},"query_defaults":{},"preserve_fields":["params","assigned"],"derived_fields":["module_assignment"],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"modules.site.get","mutation_rule":null}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'c56d24856391ea2a2705cab8abe6419c55201a4597d1fa1a67002842ab655efa', 0); +INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `seed_revision`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (346, 1, 214, 'modules.site.create.api', 'modules.site.create (API)', 'api', 'api.request', 411, 1, '{"method":"POST","route":"/v1/modules/site","route_parameters":[],"paginated":false,"body_policy":"required","operation":"create","body_defaults":{"client_id":0},"query_defaults":{},"preserve_fields":["params","assigned"],"derived_fields":["module_assignment"],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"modules.site.get","mutation_rule":null}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '65a2a4db22147683c65ef3fdda4571c630e12d61f8533d66091143f9d4914cb6', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `params`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `seed_revision`, `seed_hash`, `customized`) VALUES (347, 1, 214, 'modules.site.create.cli', 'modules.site.create (CLI)', 'cli', 'native.core-entity', 275, 285, '{"entity":{"id":"modules.site","label":"site modules","component":"com_modules","listModel":"Modules","itemModel":"Module","readFields":["id","title","note","content","ordering","position","checked_out","published","module","access","showtitle","params","client_id","language","assigned"],"writeFields":["title","note","content","ordering","position","published","module","access","showtitle","params","language","assigned"],"defaults":{"client_id":0},"modelState":{"client_id":0},"stateFilter":"filter.published","supportsState":true,"highRisk":false,"sensitiveFields":[],"primaryKey":"id","stateField":"published"},"operation":"create"}', '{"name":"modules.site.create","description":"Create site modules through fixed Joomla administrator models.","risk":"write","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_modules"},{"action":"core.create","asset":"com_modules"}],"required_extensions":["com_modules"]}', '{"verification":{"read_action":"modules.site.get","operation":"create","primary_key":"id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '2cff50f4f6b440da3c684f9995a77efad32e1a36', '582133a6475842a9d0dceda85f9b4dc1e76f8d3b3c24780b2e3befb0c3bad082', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `published`, `asset_id`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (348, 1, 215, 'modules.site.delete.api', 'modules.site.delete (API)', 'api', 'api.request', 7, 1, '{"method":"DELETE","route":"/v1/modules/site/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"none","operation":"delete","body_defaults":{"client_id":0},"query_defaults":{},"preserve_fields":["params","assigned"],"derived_fields":["module_assignment"],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"modules.site.get","mutation_rule":null}', '{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, 0, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '7ab4df28ef85708cdfedfcc71a26a8cca2a73ae0e3926a83ca02aa4577e0b7d7', 0); INSERT INTO `#__joomengine_mcp_binding` (`id`, `provider_id`, `action_id`, `name`, `title`, `track`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `params`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `seed_revision`, `seed_hash`, `customized`) VALUES (349, 1, 215, 'modules.site.delete.cli', 'modules.site.delete (CLI)', 'cli', 'native.core-entity', 8, 286, '{"entity":{"id":"modules.site","label":"site modules","component":"com_modules","listModel":"Modules","itemModel":"Module","readFields":["id","title","note","content","ordering","position","checked_out","published","module","access","showtitle","params","client_id","language","assigned"],"writeFields":["title","note","content","ordering","position","published","module","access","showtitle","params","language","assigned"],"defaults":{"client_id":0},"modelState":{"client_id":0},"stateFilter":"filter.published","supportsState":true,"highRisk":false,"sensitiveFields":[],"primaryKey":"id","stateField":"published"},"operation":"delete"}', '{"name":"modules.site.delete","description":"Delete site modules through fixed Joomla administrator models.","risk":"high","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_modules"},{"action":"core.delete","asset":"com_modules"}],"required_extensions":["com_modules"]}', '{"verification":{"read_action":"modules.site.get","operation":"delete","primary_key":"id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'f66e29b108a5ae97252cd030dd31358874e2fb561d1f32cb3d0d49dd4d193737', 0); @@ -1338,8 +1340,8 @@ INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `des INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (2, 1, 'joomla_capabilities', 'Inspect Joomla capabilities', 'Returns the source-backed core action catalogue and capabilities enabled for one configured site.', 'catalog.capabilities', 390, NULL, '{}', '{"name":"joomla_capabilities","title":"Inspect Joomla capabilities","description":"Returns the source-backed core action catalogue and capabilities enabled for one configured site.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'c6a51fb0dfadd74b1baf317ce0dd0b744f62f8649ec127c555c3105d92d3473d', 0); INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (3, 1, 'joomla_actions_search', 'Search enabled Joomla actions', 'Searches the source-backed semantic action catalogue, intersected with the selected site’s enabled toolsets. Writes are omitted unless includeWrites is explicitly true.', 'catalog.search', 391, NULL, '{}', '{"name":"joomla_actions_search","title":"Search enabled Joomla actions","description":"Searches the source-backed semantic action catalogue, intersected with the selected site’s enabled toolsets. Writes are omitted unless includeWrites is explicitly true.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '89dc50880678050e3c41ba688cad9a6c63d09c162459969ed7211ec9a69dd53c', 0); INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (4, 1, 'joomla_action_describe', 'Describe one Joomla action', 'Returns the exact source-backed route contract, typed input schema, native adapter metadata, and executable transports for one semantic action.', 'catalog.describe', 392, NULL, '{}', '{"name":"joomla_action_describe","title":"Describe one Joomla action","description":"Returns the exact source-backed route contract, typed input schema, native adapter metadata, and executable transports for one semantic action.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '59d6c081f9eb74df2b579afb8ffbbc63afbe5beb570988f0664fe158cc80d31e', 0); -INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (5, 1, 'joomla_action_read', 'Run an enabled Joomla read action', 'Executes one catalogue action by semantic identifier. Routes, origins, credentials, methods, and toolsets remain server controlled. Some source-defined GET operations perform Joomla bookkeeping; inspect the action descriptor before execution.', 'action.read', 393, NULL, '{}', '{"name":"joomla_action_read","title":"Run an enabled Joomla read action","description":"Executes one catalogue action by semantic identifier. Routes, origins, credentials, methods, and toolsets remain server controlled. Some source-defined GET operations perform Joomla bookkeeping; inspect the action descriptor before execution.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'c97872a08182eab21bf1f291ad0137069a8c3475b9fc1df4b16c49da292b3467', 0); -INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (6, 1, 'joomla_permission_request', 'Request permission for Joomla writes', 'Creates a principal-bound operator permission request for selected configured write or administration toolsets. Returns an exact acknowledgement phrase that must be shown to and supplied by the operator. It does not grant permission.', 'permission.request', 408, NULL, '{}', '{"name":"joomla_permission_request","title":"Request permission for Joomla writes","description":"Creates a principal-bound operator permission request for selected configured write or administration toolsets. Returns an exact acknowledgement phrase that must be shown to and supplied by the operator. It does not grant permission.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '8524b0c311c79cc9eaef270ab7cb86916ad1285e9aa92f3133d48e6e5778dd80', 0); +INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (5, 1, 'joomla_action_read', 'Run an enabled Joomla read action', 'Executes one catalogue action by semantic identifier. Routes, origins, credentials, methods, and toolsets remain server controlled. Some source-defined GET operations perform Joomla bookkeeping; inspect the action descriptor before execution.', 'action.read', 409, NULL, '{}', '{"name":"joomla_action_read","title":"Run an enabled Joomla read action","description":"Executes one catalogue action by semantic identifier. Routes, origins, credentials, methods, and toolsets remain server controlled. Some source-defined GET operations perform Joomla bookkeeping; inspect the action descriptor before execution.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'f57b4fc43398a38c2d965ec05a547defa84f066004276a5fd51204e3aa3b33c3', 0); +INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (6, 1, 'joomla_permission_request', 'Request permission for Joomla writes', 'Creates a principal-bound operator permission request for selected configured write or administration toolsets. Returns an exact acknowledgement phrase that must be shown to and supplied by the operator. It does not grant permission.', 'permission.request', 408, NULL, '{}', '{"name":"joomla_permission_request","title":"Request permission for Joomla writes","description":"Creates a principal-bound operator permission request for selected configured write or administration toolsets. Returns an exact acknowledgement phrase that must be shown to and supplied by the operator. It does not grant permission.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '8524b0c311c79cc9eaef270ab7cb86916ad1285e9aa92f3133d48e6e5778dd80', 0); INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (7, 1, 'joomla_permission_approve', 'Approve a requested Joomla permission', 'Validates the exact operator-supplied acknowledgement for a pending request and creates a principal-bound grant. The grant cannot exceed configured site toolsets, inbound OAuth scopes, or Joomla ACL.', 'permission.approve', 395, NULL, '{}', '{"name":"joomla_permission_approve","title":"Approve a requested Joomla permission","description":"Validates the exact operator-supplied acknowledgement for a pending request and creates a principal-bound grant. The grant cannot exceed configured site toolsets, inbound OAuth scopes, or Joomla ACL.","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '786d37b1b8fa3029c9238991684eac92b27fcf85cf92b8b563a40fe207198c9a', 0); INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (8, 1, 'joomla_permissions_list', 'List active Joomla permissions', 'Lists only the authenticated principal’s active grants, their site, toolsets, duration, expiry, and remaining use count. No acknowledgement phrase or principal identity is returned.', 'permission.list', 389, NULL, '{}', '{"name":"joomla_permissions_list","title":"List active Joomla permissions","description":"Lists only the authenticated principal’s active grants, their site, toolsets, duration, expiry, and remaining use count. No acknowledgement phrase or principal identity is returned.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'daf5cfa6301c6cebdc81d96a66333c4219a91d186fd62ff6dccce2b75fe0c7ff', 0); INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (9, 1, 'joomla_permission_revoke', 'Revoke a Joomla permission', 'Immediately revokes one active permission grant owned by the authenticated principal.', 'permission.revoke', 396, NULL, '{}', '{"name":"joomla_permission_revoke","title":"Revoke a Joomla permission","description":"Immediately revokes one active permission grant owned by the authenticated principal.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '4e32af78cee62da806cf6c97e8b8846ae9445503c5706de1a1a806a556d1931b', 0); @@ -1353,17 +1355,17 @@ INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `des INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (17, 1, 'joomla_cli_targets', 'Map Joomla CLI commands to fixed MCP actions', 'Returns the reviewed target, risk, implementation status, semantic action IDs, and installed native contract for stock Joomla CLI commands.', 'console.targets', 402, NULL, '{"tracks":["cli"]}', '{"name":"joomla_cli_targets","title":"Map Joomla CLI commands to fixed MCP actions","description":"Returns the reviewed target, risk, implementation status, semantic action IDs, and installed native contract for stock Joomla CLI commands.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '5fc57282a3299bd5c83fdd8f932847f9343015e6da57f8424379ff2f59af9e79', 0); INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (18, 1, 'joomla_companion_capabilities', 'Describe the Joomla PHP companion', 'Returns the installed companion version, effective ACL, and its strict structured-action catalogue.', 'console.capabilities', 390, NULL, '{"tracks":["cli"]}', '{"name":"joomla_companion_capabilities","title":"Describe the Joomla PHP companion","description":"Returns the installed companion version, effective ACL, and its strict structured-action catalogue.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'f4b16f513c92bcfa01685721fba0bb2f036226204f51fc22bf1f868814325ac5', 0); INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (19, 1, 'joomla_cli_inventory', 'Describe the installed Joomla CLI registry', 'Returns bounded structured metadata for installed Joomla commands and options through the companion. It does not execute the discovered commands.', 'console.inventory', 390, NULL, '{"tracks":["cli"]}', '{"name":"joomla_cli_inventory","title":"Describe the installed Joomla CLI registry","description":"Returns bounded structured metadata for installed Joomla commands and options through the companion. It does not execute the discovered commands.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'e70c70f6b408929919511baf4256fbd6ff141f4e3c363e0a342c3f356f17b604', 0); -INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (20, 1, 'joomla_companion_action_read', 'Run a Joomla-native companion read action', 'Runs one fixed Joomla-native structured read action through JSON stdin. No Joomla command, shell, path, URL, or credential is accepted.', 'action.read', 403, NULL, '{"tracks":["cli"],"transport":"cli"}', '{"name":"joomla_companion_action_read","title":"Run a Joomla-native companion read action","description":"Runs one fixed Joomla-native structured read action through JSON stdin. No Joomla command, shell, path, URL, or credential is accepted.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '4d0ad1be7d4261ac5fad1dd32a0e480a1866a8581cf241d523da657ef6de9817', 0); +INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (20, 1, 'joomla_companion_action_read', 'Run a Joomla-native companion read action', 'Runs one fixed Joomla-native structured read action through JSON stdin. No Joomla command, shell, path, URL, or credential is accepted.', 'action.read', 410, NULL, '{"tracks":["cli"],"transport":"cli"}', '{"name":"joomla_companion_action_read","title":"Run a Joomla-native companion read action","description":"Runs one fixed Joomla-native structured read action through JSON stdin. No Joomla command, shell, path, URL, or credential is accepted.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'cffba457e204f5f19cd46495c77b6804ae142db56569fd8dd86c20868b28ac4d', 0); INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (21, 1, 'joomla_content_article_create_plan', 'Plan a Joomla article creation', 'Validates a flat Joomla article form and returns a short-lived confirmation token without performing the write.', 'action.plan', 404, NULL, '{"action":"content.articles.create","input_from":"arguments"}', '{"name":"joomla_content_article_create_plan","title":"Plan a Joomla article creation","description":"Validates a flat Joomla article form and returns a short-lived confirmation token without performing the write.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'a251bddec8c268a0b1bd7017ccd7c54c84341b49b57c9664f1aa58f9a1c182ad', 0); INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (22, 1, 'joomla_content_article_update_plan', 'Plan a Joomla article update', 'Validates an allowlisted partial article update and returns a short-lived confirmation token.', 'action.plan', 405, NULL, '{"action":"content.articles.update","input_from":"arguments"}', '{"name":"joomla_content_article_update_plan","title":"Plan a Joomla article update","description":"Validates an allowlisted partial article update and returns a short-lived confirmation token.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '2cc6b076723dc6c5707fa660c2184a2d205f179860049ff4e42afb2d602506da', 0); INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (23, 1, 'joomla_content_article_delete_plan', 'Plan a Joomla article deletion', 'Plans resource-model deletion for one article and returns a short-lived confirmation token.', 'action.plan', 406, NULL, '{"action":"content.articles.delete","input_from":"arguments"}', '{"name":"joomla_content_article_delete_plan","title":"Plan a Joomla article deletion","description":"Plans resource-model deletion for one article and returns a short-lived confirmation token.","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', '6794059449b00fd5b700137f1df0bad1dd48d03a6d49c2bd06514e9e54261888', 0); INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_revision`, `seed_hash`, `customized`) VALUES (24, 1, 'joomla_write_apply', 'Apply one planned Joomla write', 'Consumes a short-lived, signed, one-time confirmation token and applies exactly the previously validated operation.', 'action.apply', 407, NULL, '{}', '{"name":"joomla_write_apply","title":"Apply one planned Joomla write","description":"Consumes a short-lived, signed, one-time confirmation token and applies exactly the previously validated operation.","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '2cff50f4f6b440da3c684f9995a77efad32e1a36', 'af708f0c51387c14ab0572bd77b18f4c360dc3585fc7304ae0ae05f90ef38685', 0); -INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (25, 1, 'joomla_jobs_list', 'List owned jobs', 'List durable jobs belonging to the current authenticated principal. Status includes known cancellation, completion and uncertain outcomes.', 'job.list', 411, 1, '{}', '{"name":"joomla_jobs_list","title":"List owned jobs","description":"List durable jobs belonging to the current authenticated principal. Status includes known cancellation, completion and uncertain outcomes.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '7395ef732a01cc75e158634613b96a931ee7353e2d9d80c27c3a538e528b697a', 0); -INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (26, 1, 'joomla_job_status', 'Inspect an owned job', 'Read the current durable state and retained result of an owned job. An expired worker lease is reported as uncertain; a disconnected request is not proof of rollback.', 'job.status', 412, 1, '{}', '{"name":"joomla_job_status","title":"Inspect an owned job","description":"Read the current durable state and retained result of an owned job. An expired worker lease is reported as uncertain; a disconnected request is not proof of rollback.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '0698a762b00e4a519c1c1464c0a4068d513eb704dca480d4ee28deadf2a5fc43', 0); -INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (27, 1, 'joomla_job_cancel', 'Cancel an owned job', 'Request cancellation of an owned job. Queued work is cancelled before execution; running work may have partial effects. Inspect the returned state and reconcile uncertain outcomes.', 'job.cancel', 412, 1, '{}', '{"name":"joomla_job_cancel","title":"Cancel an owned job","description":"Request cancellation of an owned job. Queued work is cancelled before execution; running work may have partial effects. Inspect the returned state and reconcile uncertain outcomes.","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '911f92ddcc571a7af996c3c0177cbf3af3c4a62e7dbb660803d6a80feab0032a', 0); -INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (28, 1, 'joomla_job_redispatch', 'Redispatch an unstarted job', 'Redispatch only a never-started queued job using its existing approved execution claim. Running, cancelled and uncertain jobs cannot be replayed.', 'job.redispatch', 412, 1, '{}', '{"name":"joomla_job_redispatch","title":"Redispatch an unstarted job","description":"Redispatch only a never-started queued job using its existing approved execution claim. Running, cancelled and uncertain jobs cannot be replayed.","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '490f9f518e87b7529cff2082eb024cf877f4384059072e256330ea23062f987a', 0); -INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (29, 1, 'joomla_job_artifacts', 'List owned job artifacts', 'List immutable retained artifact metadata for an owned job. The server returns opaque artifact identifiers, never selectable filesystem paths.', 'job.artifacts', 412, 1, '{}', '{"name":"joomla_job_artifacts","title":"List owned job artifacts","description":"List immutable retained artifact metadata for an owned job. The server returns opaque artifact identifiers, never selectable filesystem paths.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'e0a2fb614aab6af1f86c9c1dc827032e626ece4172edf82115c0e37a46cf2fda', 0); -INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (30, 1, 'joomla_job_artifact_read', 'Read an owned artifact chunk', 'Read and verify a bounded base64-encoded chunk of a retained owned artifact. Use the returned offsets and hashes to assemble the file; no local or remote path input is accepted.', 'job.artifact.read', 413, 1, '{}', '{"name":"joomla_job_artifact_read","title":"Read an owned artifact chunk","description":"Read and verify a bounded base64-encoded chunk of a retained owned artifact. Use the returned offsets and hashes to assemble the file; no local or remote path input is accepted.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', '79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '8ce7e9b05ffad0d9aef126b8063c06be324faa825c2faa70265db587c728ad93', 0); +INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (25, 1, 'joomla_jobs_list', 'List owned jobs', 'List durable jobs belonging to the current authenticated principal. Status includes known cancellation, completion and uncertain outcomes.', 'job.list', 413, 1, '{}', '{"name":"joomla_jobs_list","title":"List owned jobs","description":"List durable jobs belonging to the current authenticated principal. Status includes known cancellation, completion and uncertain outcomes.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '39a0dcca02fe5fa9f11378a190f85e5642ce97ac8f9963d345f9d6c36680ffcf', 0); +INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (26, 1, 'joomla_job_status', 'Inspect an owned job', 'Read the current durable state and retained result of an owned job. An expired worker lease is reported as uncertain; a disconnected request is not proof of rollback.', 'job.status', 414, 1, '{}', '{"name":"joomla_job_status","title":"Inspect an owned job","description":"Read the current durable state and retained result of an owned job. An expired worker lease is reported as uncertain; a disconnected request is not proof of rollback.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'a55b11a0897352872ee04eb46884d3710c96fe31bb287079fc31615caf2bfab1', 0); +INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (27, 1, 'joomla_job_cancel', 'Cancel an owned job', 'Request cancellation of an owned job. Queued work is cancelled before execution; running work may have partial effects. Inspect the returned state and reconcile uncertain outcomes.', 'job.cancel', 414, 1, '{}', '{"name":"joomla_job_cancel","title":"Cancel an owned job","description":"Request cancellation of an owned job. Queued work is cancelled before execution; running work may have partial effects. Inspect the returned state and reconcile uncertain outcomes.","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'b5c44d8938d400ab7190dc2e3cce9e7f673b25a2ba1a5a632579051bcd88d54d', 0); +INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (28, 1, 'joomla_job_redispatch', 'Redispatch an unstarted job', 'Redispatch only a never-started queued job using its existing approved execution claim. Running, cancelled and uncertain jobs cannot be replayed.', 'job.redispatch', 414, 1, '{}', '{"name":"joomla_job_redispatch","title":"Redispatch an unstarted job","description":"Redispatch only a never-started queued job using its existing approved execution claim. Running, cancelled and uncertain jobs cannot be replayed.","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', '46d1a5dd2902850669093bfeaad5e31f2c7b8a7d6796a7948c4b9986fa9013ec', 0); +INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (29, 1, 'joomla_job_artifacts', 'List owned job artifacts', 'List immutable retained artifact metadata for an owned job. The server returns opaque artifact identifiers, never selectable filesystem paths.', 'job.artifacts', 414, 1, '{}', '{"name":"joomla_job_artifacts","title":"List owned job artifacts","description":"List immutable retained artifact metadata for an owned job. The server returns opaque artifact identifiers, never selectable filesystem paths.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'e39a2637af0636d020d420a674a8c02a6958dd3e90e8a405f46c85d86631e70b', 0); +INSERT INTO `#__joomengine_mcp_tool` (`id`, `provider_id`, `name`, `title`, `description`, `handler`, `input_schema_id`, `output_schema_id`, `configuration`, `definition`, `seed_revision`, `asset_id`, `published`, `access`, `ordering`, `checked_out`, `checked_out_time`, `created`, `created_by`, `modified`, `modified_by`, `version`, `params`, `seed_hash`, `customized`) VALUES (30, 1, 'joomla_job_artifact_read', 'Read an owned artifact chunk', 'Read and verify a bounded base64-encoded chunk of a retained owned artifact. Use the returned offsets and hashes to assemble the file; no local or remote path input is accepted.', 'job.artifact.read', 415, 1, '{}', '{"name":"joomla_job_artifact_read","title":"Read an owned artifact chunk","description":"Read and verify a bounded base64-encoded chunk of a retained owned artifact. Use the returned offsets and hashes to assemble the file; no local or remote path input is accepted.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, '2026-09-17 00:00:00', 0, NULL, 0, 1, '{}', 'ca08e219ef0bc569115777d4d205c45316d101ad40ab9927bca39b5eb6327137', 0); CREATE TABLE IF NOT EXISTS `#__joomengine_mcp_resource` ( `id` INT NOT NULL AUTO_INCREMENT, diff --git a/admin/sql/install.postgresql.utf8.sql b/admin/sql/install.postgresql.utf8.sql index d394ff6..a405df3 100644 --- a/admin/sql/install.postgresql.utf8.sql +++ b/admin/sql/install.postgresql.utf8.sql @@ -464,12 +464,14 @@ INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "d INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (405, 1, E'schema.94e14f6a626475276e8ad8e6a53554fda281b3a308afae523a28eafa9165aaa1', E'Schema 94e14f6a6264', E'{"type":"object","properties":{"site":{"description":"Configured site alias; omit for the default site.","type":"string","minLength":1},"id":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idempotencyKey":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"etag":{"type":"string","maxLength":512},"data":{"type":"object","properties":{"title":{"type":"string","minLength":1,"maxLength":255},"catid":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"alias":{"type":"string","maxLength":400},"articletext":{"type":"string","maxLength":5000000},"introtext":{"type":"string","maxLength":2000000},"fulltext":{"type":"string","maxLength":3000000},"state":{"type":"integer","minimum":-2,"maximum":1},"access":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"featured":{"anyOf":[{"type":"boolean"},{"type":"integer","minimum":0,"maximum":1}]},"language":{"type":"string","maxLength":50},"metadesc":{"type":"string","maxLength":1000},"metakey":{"type":"string","maxLength":1000},"publish_up":{"anyOf":[{"type":"string","format":"date-time","pattern":"^(?:(?:\\\\d\\\\d[2468][048]|\\\\d\\\\d[13579][26]|\\\\d\\\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\\\d|30)|(?:02)-(?:0[1-9]|1\\\\d|2[0-8])))T(?:(?:[01]\\\\d|2[0-3]):[0-5]\\\\d(?::[0-5]\\\\d(?:\\\\.\\\\d+)?)?(?:Z|))$"},{"type":"null"}]},"publish_down":{"anyOf":[{"type":"string","format":"date-time","pattern":"^(?:(?:\\\\d\\\\d[2468][048]|\\\\d\\\\d[13579][26]|\\\\d\\\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\\\d|30)|(?:02)-(?:0[1-9]|1\\\\d|2[0-8])))T(?:(?:[01]\\\\d|2[0-3]):[0-5]\\\\d(?::[0-5]\\\\d(?:\\\\.\\\\d+)?)?(?:Z|))$"},{"type":"null"}]}}}},"required":["id","idempotencyKey","data"],"$schema":"http://json-schema.org/draft-07/schema#"}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'0ee0d356b6eaba5de77c6570acf1993ae48e7707ecd30161df73f4d49972792e', 0); INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (406, 1, E'schema.799f5bd3bf92211ee95992a5ae189930ba47bae76ea7ffc3abfa1510cd6dddc5', E'Schema 799f5bd3bf92', E'{"type":"object","properties":{"site":{"description":"Configured site alias; omit for the default site.","type":"string","minLength":1},"id":{"type":"integer","exclusiveMinimum":0,"maximum":9007199254740991},"idempotencyKey":{"type":"string","format":"uuid","pattern":"^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$"},"etag":{"type":"string","maxLength":512}},"required":["id","idempotencyKey"],"$schema":"http://json-schema.org/draft-07/schema#"}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'400759d3dc2f7dca0bfcd7d962654f1f997d1611830c24e491f8cb85c2f94559', 0); INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (407, 1, E'schema.9b11202cb0cc4d613d538e23fdfe31fa8f6310d26c066637356a1e41370825d9', E'Schema 9b11202cb0cc', E'{"type":"object","properties":{"confirmationToken":{"type":"string","minLength":64,"maxLength":4096}},"required":["confirmationToken"],"$schema":"http://json-schema.org/draft-07/schema#"}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'3dccae787bae85cb572d24eee9ac1591df14a6dfdccdaa3098e24416c383a4d0', 0); -INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (408, 1, E'schema.15fb5389bc29b0c83f4bf179870c7f89775fbe0da8e1d24f43af140b462d2049', E'Schema 15fb5389bc29', E'{"type":"object","properties":{"site":{"description":"Configured site alias; omit for the default site.","type":"string","minLength":1},"toolsets":{"description":"Enabled write toolset names from this server''s action catalogue. Each scope is checked against the current Joomla identity.","type":"array","minItems":1,"maxItems":64,"items":{"type":"string","minLength":1,"maxLength":190}},"duration":{"type":"string","enum":["once","30-minutes","indefinite"]},"reason":{"type":"string","minLength":3,"maxLength":500}},"required":["toolsets","duration","reason"],"$schema":"http://json-schema.org/draft-07/schema#"}', E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'258abfe5e76373a6e695627204c4851519146b821d9d1396a3528b7b94a0b1db', 0); -INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (409, 1, E'schema.37cde43b5df6f7f600f1f4410f2a4706d53e935460124c699cab61e68f36aee3', E'Schema 37cde43b5df6', E'{"type":"object","properties":{"data":{"type":"object","minProperties":1,"maxProperties":512,"properties":{"title":{"description":"Reviewed Joomla site module form field."},"note":{"description":"Reviewed Joomla site module form field."},"content":{"description":"Reviewed Joomla site module form field."},"ordering":{"type":"integer","description":"Use zero or omit ordering to let Joomla assign the next order for this module position. A nonzero ordering requests that exact value."},"position":{"description":"Reviewed Joomla site module form field."},"published":{"description":"Reviewed Joomla site module form field."},"module":{"description":"Reviewed Joomla site module form field."},"access":{"description":"Reviewed Joomla site module form field."},"showtitle":{"description":"Reviewed Joomla site module form field."},"params":{"description":"Reviewed Joomla site module form field."},"language":{"description":"Reviewed Joomla site module form field."},"assigned":{"description":"Reviewed Joomla site module form field."}},"additionalProperties":false,"description":"Allowlisted Joomla form JSON. Joomla validates resource-specific values and ACL."}},"required":["data"],"additionalProperties":false}', E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'5a8e7737e17af89a3a12b449cef973fa75d0958e53afaa6555b108de27c8c38a', 0); -INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (410, 1, E'schema.edd0e2e7b8da488986a2b08ed09766131403db7f5719abb2a007401f8b6df409', E'Schema edd0e2e7b8da', E'{"type":"object","properties":{"data":{"type":"object","minProperties":1,"maxProperties":512,"properties":{"title":{"description":"Reviewed Joomla administrator module form field."},"note":{"description":"Reviewed Joomla administrator module form field."},"content":{"description":"Reviewed Joomla administrator module form field."},"ordering":{"type":"integer","description":"Use zero or omit ordering to let Joomla assign the next order for this module position. A nonzero ordering requests that exact value."},"position":{"description":"Reviewed Joomla administrator module form field."},"published":{"description":"Reviewed Joomla administrator module form field."},"module":{"description":"Reviewed Joomla administrator module form field."},"access":{"description":"Reviewed Joomla administrator module form field."},"showtitle":{"description":"Reviewed Joomla administrator module form field."},"params":{"description":"Reviewed Joomla administrator module form field."},"language":{"description":"Reviewed Joomla administrator module form field."},"assigned":{"description":"Reviewed Joomla administrator module form field."}},"additionalProperties":false,"description":"Allowlisted Joomla form JSON. Joomla validates resource-specific values and ACL."}},"required":["data"],"additionalProperties":false}', E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'e9c49ed54ceee7a24fb9143a6269741a460ea23f2f3351505fa8742ddbf47699', 0); -INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (411, 1, E'schema.514ff59fe2ec7de14b1a0bb8dce0754164a69ca1e795605c6afc13cfd2b63159', E'Schema 514ff59fe2ec', E'{"type":"object","properties":{"site":{"type":"string","minLength":1,"maxLength":190,"description":"Configured installation alias; omit to use this server."},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"offset":{"type":"integer","minimum":0,"maximum":1000000,"default":0}},"required":[],"additionalProperties":false}', E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'a43985614cf0194fb0d8e3e03b99a8960326e489c9973d1f878fe776a2958440', 0); -INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (412, 1, E'schema.33d5e81d0321fc7ccb4e3210a6b2e80afa0ddc60d2e7307bc47f02f46330ec4a', E'Schema 33d5e81d0321', E'{"type":"object","properties":{"site":{"type":"string","minLength":1,"maxLength":190,"description":"Configured installation alias; omit to use this server."},"jobId":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$","description":"Opaque identifier returned by this server for the authenticated principal."}},"required":["jobId"],"additionalProperties":false}', E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'eabccb8d39b47526cae04180f447781c76c2c8b06256cbf34510022742a71238', 0); -INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (413, 1, E'schema.40995628f009ab15cecc1a0ca487ca74c7577b0331d36ab1e4eeeeca9de5460b', E'Schema 40995628f009', E'{"type":"object","properties":{"site":{"type":"string","minLength":1,"maxLength":190,"description":"Configured installation alias; omit to use this server."},"artifactId":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$","description":"Opaque identifier returned by this server for the authenticated principal."},"offset":{"type":"integer","minimum":0,"default":0},"length":{"type":"integer","minimum":1,"maximum":262144,"default":65536}},"required":["artifactId"],"additionalProperties":false}', E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'4ad1570a752bb38ca99d045c8fffa66ceec7961185214c138303709dc579f20e', 0); +INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (408, 1, E'schema.15fb5389bc29b0c83f4bf179870c7f89775fbe0da8e1d24f43af140b462d2049', E'Schema 15fb5389bc29', E'{"type":"object","properties":{"site":{"description":"Configured site alias; omit for the default site.","type":"string","minLength":1},"toolsets":{"description":"Enabled write toolset names from this server''s action catalogue. Each scope is checked against the current Joomla identity.","type":"array","minItems":1,"maxItems":64,"items":{"type":"string","minLength":1,"maxLength":190}},"duration":{"type":"string","enum":["once","30-minutes","indefinite"]},"reason":{"type":"string","minLength":3,"maxLength":500}},"required":["toolsets","duration","reason"],"$schema":"http://json-schema.org/draft-07/schema#"}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'258abfe5e76373a6e695627204c4851519146b821d9d1396a3528b7b94a0b1db', 0); +INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (409, 1, E'schema.c36a04dc5114ed333c8e8d846cdddd9b21f3b50075bcecb0e951df2853e55f7f', E'Schema c36a04dc5114', E'{"type":"object","properties":{"site":{"description":"Configured site alias; omit for the default site.","type":"string","minLength":1},"action":{"type":"string","minLength":3,"maxLength":160},"input":{"description":"Bounded JSON argument object validated again against the selected action schema. Nested objects and arrays retain their JSON types.","default":{},"type":"object","maxProperties":512,"propertyNames":{"type":"string"},"additionalProperties":{}},"transport":{"default":"auto","type":"string","enum":["auto","api","cli"]}},"required":["action"],"$schema":"http://json-schema.org/draft-07/schema#"}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'c4f360e0818319706398fe921e3cd4bdb05475c258db491200573ed1031db7c9', 0); +INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (410, 1, E'schema.d5e18a6816a315862f4898d7eb178d8ab5754f88a1a9168cb3f3b7ac155d250b', E'Schema d5e18a6816a3', E'{"type":"object","properties":{"site":{"description":"Configured site alias; omit for the default site.","type":"string","minLength":1},"action":{"type":"string","minLength":3,"maxLength":160},"input":{"description":"Bounded JSON argument object validated again against the selected native action schema. Nested objects and arrays retain their JSON types.","default":{},"type":"object","maxProperties":512,"propertyNames":{"type":"string"},"additionalProperties":{}}},"required":["action"],"$schema":"http://json-schema.org/draft-07/schema#"}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'19e026f56233b39a274c80f259062dab39ee425d7143c3f030e58b9a54164a7f', 0); +INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (411, 1, E'schema.37cde43b5df6f7f600f1f4410f2a4706d53e935460124c699cab61e68f36aee3', E'Schema 37cde43b5df6', E'{"type":"object","properties":{"data":{"type":"object","minProperties":1,"maxProperties":512,"properties":{"title":{"description":"Reviewed Joomla site module form field."},"note":{"description":"Reviewed Joomla site module form field."},"content":{"description":"Reviewed Joomla site module form field."},"ordering":{"type":"integer","description":"Use zero or omit ordering to let Joomla assign the next order for this module position. A nonzero ordering requests that exact value."},"position":{"description":"Reviewed Joomla site module form field."},"published":{"description":"Reviewed Joomla site module form field."},"module":{"description":"Reviewed Joomla site module form field."},"access":{"description":"Reviewed Joomla site module form field."},"showtitle":{"description":"Reviewed Joomla site module form field."},"params":{"description":"Reviewed Joomla site module form field."},"language":{"description":"Reviewed Joomla site module form field."},"assigned":{"description":"Reviewed Joomla site module form field."}},"additionalProperties":false,"description":"Allowlisted Joomla form JSON. Joomla validates resource-specific values and ACL."}},"required":["data"],"additionalProperties":false}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'5a8e7737e17af89a3a12b449cef973fa75d0958e53afaa6555b108de27c8c38a', 0); +INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (412, 1, E'schema.edd0e2e7b8da488986a2b08ed09766131403db7f5719abb2a007401f8b6df409', E'Schema edd0e2e7b8da', E'{"type":"object","properties":{"data":{"type":"object","minProperties":1,"maxProperties":512,"properties":{"title":{"description":"Reviewed Joomla administrator module form field."},"note":{"description":"Reviewed Joomla administrator module form field."},"content":{"description":"Reviewed Joomla administrator module form field."},"ordering":{"type":"integer","description":"Use zero or omit ordering to let Joomla assign the next order for this module position. A nonzero ordering requests that exact value."},"position":{"description":"Reviewed Joomla administrator module form field."},"published":{"description":"Reviewed Joomla administrator module form field."},"module":{"description":"Reviewed Joomla administrator module form field."},"access":{"description":"Reviewed Joomla administrator module form field."},"showtitle":{"description":"Reviewed Joomla administrator module form field."},"params":{"description":"Reviewed Joomla administrator module form field."},"language":{"description":"Reviewed Joomla administrator module form field."},"assigned":{"description":"Reviewed Joomla administrator module form field."}},"additionalProperties":false,"description":"Allowlisted Joomla form JSON. Joomla validates resource-specific values and ACL."}},"required":["data"],"additionalProperties":false}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'e9c49ed54ceee7a24fb9143a6269741a460ea23f2f3351505fa8742ddbf47699', 0); +INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (413, 1, E'schema.514ff59fe2ec7de14b1a0bb8dce0754164a69ca1e795605c6afc13cfd2b63159', E'Schema 514ff59fe2ec', E'{"type":"object","properties":{"site":{"type":"string","minLength":1,"maxLength":190,"description":"Configured installation alias; omit to use this server."},"limit":{"type":"integer","minimum":1,"maximum":100,"default":50},"offset":{"type":"integer","minimum":0,"maximum":1000000,"default":0}},"required":[],"additionalProperties":false}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'a43985614cf0194fb0d8e3e03b99a8960326e489c9973d1f878fe776a2958440', 0); +INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (414, 1, E'schema.33d5e81d0321fc7ccb4e3210a6b2e80afa0ddc60d2e7307bc47f02f46330ec4a', E'Schema 33d5e81d0321', E'{"type":"object","properties":{"site":{"type":"string","minLength":1,"maxLength":190,"description":"Configured installation alias; omit to use this server."},"jobId":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$","description":"Opaque identifier returned by this server for the authenticated principal."}},"required":["jobId"],"additionalProperties":false}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'eabccb8d39b47526cae04180f447781c76c2c8b06256cbf34510022742a71238', 0); +INSERT INTO "#__joomengine_mcp_schema" ("id", "provider_id", "name", "title", "document", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (415, 1, E'schema.40995628f009ab15cecc1a0ca487ca74c7577b0331d36ab1e4eeeeca9de5460b', E'Schema 40995628f009', E'{"type":"object","properties":{"site":{"type":"string","minLength":1,"maxLength":190,"description":"Configured installation alias; omit to use this server."},"artifactId":{"type":"string","pattern":"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$","description":"Opaque identifier returned by this server for the authenticated principal."},"offset":{"type":"integer","minimum":0,"default":0},"length":{"type":"integer","minimum":1,"maximum":262144,"default":65536}},"required":["artifactId"],"additionalProperties":false}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'4ad1570a752bb38ca99d045c8fffa66ceec7961185214c138303709dc579f20e', 0); SELECT setval(pg_get_serial_sequence(E'#__joomengine_mcp_schema', 'id'), (SELECT MAX("id") FROM "#__joomengine_mcp_schema"), true); CREATE TABLE IF NOT EXISTS "#__joomengine_mcp_action" ( @@ -711,7 +713,7 @@ INSERT INTO "#__joomengine_mcp_action" ("id", "provider_id", "name", "title", "d INSERT INTO "#__joomengine_mcp_action" ("id", "provider_id", "name", "title", "description", "domain", "toolset", "effect", "risk", "input_schema_id", "output_schema_id", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (200, 1, E'menus.site.update', E'Update Site menu', E'Updates site menu through Joomla''s menus API controller.', E'menus', E'structure.write', E'write', E'write', 262, 1, E'{"id":"menus.site.update","title":"Update Site menu","description":"Updates site menu through Joomla''s menus API controller.","domain":"menus","operation":"update","method":"PATCH","routeTemplate":"v1/menus/site/:id","routeParameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"bodyPolicy":"required","toolset":"structure.write","risk":"write","acl":{"apiLogin":"core.login.api","component":"com_menus","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/menus","responseShape":"json-api","mutationBody":"flat-joomla-form-json"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/menus/src/Extension/Menus.php","registration":"createCRUDRoutes"},"sourceGate":null}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'a53852207a3ede85f78086b126afd637cd86119ded234c2ea593378363352bc3', 0); INSERT INTO "#__joomengine_mcp_action" ("id", "provider_id", "name", "title", "description", "domain", "toolset", "effect", "risk", "input_schema_id", "output_schema_id", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (201, 1, E'messages.messages.create', E'Create Private message', E'Creates private message through Joomla''s messages API controller.', E'messages', E'users.admin', E'write', E'write', 264, 1, E'{"id":"messages.messages.create","title":"Create Private message","description":"Creates private message through Joomla''s messages API controller.","domain":"messages","operation":"create","method":"POST","routeTemplate":"v1/messages","routeParameters":[],"bodyPolicy":"required","toolset":"users.admin","risk":"write","acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/messages","responseShape":"json-api","mutationBody":"flat-joomla-form-json"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"sourceGate":null}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'71700e219f56c0534e78dd53f3ab7151ce13f6fe9ca48718a891dfda8a354ffd', 0); INSERT INTO "#__joomengine_mcp_action" ("id", "provider_id", "name", "title", "description", "domain", "toolset", "effect", "risk", "input_schema_id", "output_schema_id", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (202, 1, E'messages.messages.delete', E'Delete Private message', E'Deletes private message through Joomla''s messages API controller.', E'messages', E'users.admin', E'write', E'destructive', 7, 1, E'{"id":"messages.messages.delete","title":"Delete Private message","description":"Deletes private message through Joomla''s messages API controller.","domain":"messages","operation":"delete","method":"DELETE","routeTemplate":"v1/messages/:id","routeParameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"bodyPolicy":"none","toolset":"users.admin","risk":"destructive","acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/messages","responseShape":"json-api","mutationBody":"flat-joomla-form-json"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"sourceGate":null}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'6e873d11bf228290af8f2cbee199863e621dd4444dc6afe0b273997ef7294710', 0); -INSERT INTO "#__joomengine_mcp_action" ("id", "provider_id", "name", "title", "description", "domain", "toolset", "effect", "risk", "input_schema_id", "output_schema_id", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (203, 1, E'messages.messages.get', E'Get Private message', E'Gets one private message by numeric identifier through Joomla''s native API. The native item GET marks the stored message read and may return the representation loaded before that change. Eligible local, uncustomized write planning uses a separately declared recipient-scoped native-owned-record snapshot without marking the message read.', E'messages', E'users.read', E'read', E'sensitive-read', 10, 1, E'{"id":"messages.messages.get","title":"Get Private message","description":"Gets one private message by numeric identifier through Joomla''s native API. The native item GET marks the stored message read and may return the representation loaded before that change. Eligible local, uncustomized write planning uses a separately declared recipient-scoped native-owned-record snapshot without marking the message read.","domain":"messages","operation":"get","method":"GET","routeTemplate":"v1/messages/:id","routeParameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"toolset":"users.read","risk":"sensitive-read","sideEffect":true,"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/messages","responseShape":"json-api","mutationBody":"not-applicable"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"sourceGate":null}', E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'0fc426670f60649455c397d62c4579b4970e62317ec3e6f3d5072cb46e43ec86', 0); +INSERT INTO "#__joomengine_mcp_action" ("id", "provider_id", "name", "title", "description", "domain", "toolset", "effect", "risk", "input_schema_id", "output_schema_id", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (203, 1, E'messages.messages.get', E'Get Private message', E'Gets one private message by numeric identifier through Joomla''s native API. The native item GET marks the stored message read and may return the representation loaded before that change. Eligible local, uncustomized write planning uses a separately declared recipient-scoped native-owned-record snapshot without marking the message read.', E'messages', E'users.read', E'read', E'sensitive-read', 10, 1, E'{"id":"messages.messages.get","title":"Get Private message","description":"Gets one private message by numeric identifier through Joomla''s native API. The native item GET marks the stored message read and may return the representation loaded before that change. Eligible local, uncustomized write planning uses a separately declared recipient-scoped native-owned-record snapshot without marking the message read.","domain":"messages","operation":"get","method":"GET","routeTemplate":"v1/messages/:id","routeParameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"toolset":"users.read","risk":"sensitive-read","sideEffect":true,"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/messages","responseShape":"json-api","mutationBody":"not-applicable"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"sourceGate":null}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'0fc426670f60649455c397d62c4579b4970e62317ec3e6f3d5072cb46e43ec86', 0); INSERT INTO "#__joomengine_mcp_action" ("id", "provider_id", "name", "title", "description", "domain", "toolset", "effect", "risk", "input_schema_id", "output_schema_id", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (204, 1, E'messages.messages.list', E'List Private messages', E'Lists private messages through Joomla''s messages API controller.', E'messages', E'users.read', E'read', E'sensitive-read', 13, 1, E'{"id":"messages.messages.list","title":"List Private messages","description":"Lists private messages through Joomla''s messages API controller.","domain":"messages","operation":"list","method":"GET","routeTemplate":"v1/messages","routeParameters":[],"paginated":true,"toolset":"users.read","risk":"sensitive-read","sideEffect":false,"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/messages","responseShape":"json-api","mutationBody":"not-applicable"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"sourceGate":null}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'4f37ea2f2703399fb7de040e4be5be37834956d97215610d951d144bee4bc8ec', 0); INSERT INTO "#__joomengine_mcp_action" ("id", "provider_id", "name", "title", "description", "domain", "toolset", "effect", "risk", "input_schema_id", "output_schema_id", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (205, 1, E'messages.messages.update', E'Update Private message', E'Updates private message through Joomla''s messages API controller.', E'messages', E'users.admin', E'write', E'write', 271, 1, E'{"id":"messages.messages.update","title":"Update Private message","description":"Updates private message through Joomla''s messages API controller.","domain":"messages","operation":"update","method":"PATCH","routeTemplate":"v1/messages/:id","routeParameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"bodyPolicy":"required","toolset":"users.admin","risk":"write","acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/messages","responseShape":"json-api","mutationBody":"flat-joomla-form-json"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"sourceGate":null}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'e3ab358bd780e3f5618b14af7b8a8d5eee90b1e50d79f77eda87f1cd5a6af41e', 0); INSERT INTO "#__joomengine_mcp_action" ("id", "provider_id", "name", "title", "description", "domain", "toolset", "effect", "risk", "input_schema_id", "output_schema_id", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (206, 1, E'modules.administrator-types.list', E'List administrator module types', E'Lists available module types for the administrator client.', E'modules', E'structure.read', E'read', E'read', 49, 1, E'{"id":"modules.administrator-types.list","title":"List administrator module types","description":"Lists available module types for the administrator client.","domain":"modules","operation":"list","method":"GET","routeTemplate":"v1/modules/types/administrator","routeParameters":[],"paginated":false,"toolset":"structure.read","risk":"read","sideEffect":false,"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"driver":{"kind":"joomla-api","transport":"https","authentication":"joomla-api-token","plugin":"webservices/modules","responseShape":"json-api","mutationBody":"not-applicable"},"versions":{"baseline":"6.1-dev","compatible":"6.2-dev","canary":"7.0-dev","minimum":"6.1.0","maximumExclusive":"8.0.0","examinedHeads":{"6.1-dev":"071afb7ad305c02983a653ccfc301b5c8360264b","6.2-dev":"df0e57da1cf3febfd8d4da0c522b87f3d5c6aec5","7.0-dev":"b3a08ce4cbca0c77ff34c9b1abe1c431536dbb3f"}},"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"Route"},"sourceGate":null}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'15cfcbe9da6d6fc27df46893eeac6655f2df7ed6ea1dcf3c7eb8c1fa9e394d8d', 0); @@ -1158,16 +1160,16 @@ INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "params", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "seed_revision", "seed_hash", "customized") VALUES (322, 1, 200, E'menus.site.update.cli', E'menus.site.update (CLI)', E'cli', E'native.core-entity', 247, 263, E'{"entity":{"id":"menus.site","label":"site menus","component":"com_menus","listModel":"Menus","itemModel":"Menu","readFields":["id","menutype","title","description","client_id"],"writeFields":["menutype","title","description"],"defaults":{"client_id":0},"modelState":{"client_id":0},"stateFilter":"filter.published","supportsState":false,"highRisk":false,"sensitiveFields":[],"primaryKey":"id","stateField":"published"},"operation":"update"}', E'{"name":"menus.site.update","description":"Update site menus through fixed Joomla administrator models.","risk":"write","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_menus"},{"action":"core.edit","asset":"com_menus"}],"required_extensions":["com_menus"]}', E'{"verification":{"read_action":"menus.site.get","operation":"update","primary_key":"id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'1146cb835098bc57edcb619ff82733db3bf3a423f54e172cf3a3ba45726753c3', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (323, 1, 201, E'messages.messages.create.api', E'messages.messages.create (API)', E'api', E'api.request', 264, 1, E'{"method":"POST","route":"/v1/messages","route_parameters":[],"paginated":false,"body_policy":"required","operation":"create","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"messages.messages.get","mutation_rule":null}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'ad693eb44ffb7270ca8dc74febd45c129044b360a8e6422261c587a0ee96838a', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "params", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "seed_revision", "seed_hash", "customized") VALUES (324, 1, 201, E'messages.messages.create.cli', E'messages.messages.create (CLI)', E'cli', E'native.core-entity', 265, 266, E'{"entity":{"id":"messages.messages","label":"private messages","component":"com_messages","listModel":"Messages","itemModel":"Message","readFields":["message_id","id","user_id_from","user_id_to","folder_id","date_time","state","priority","subject","message"],"writeFields":["user_id_to","folder_id","state","priority","subject","message"],"defaults":[],"modelState":[],"stateFilter":"filter.state","supportsState":false,"highRisk":true,"sensitiveFields":[],"primaryKey":"message_id","stateField":"published"},"operation":"create"}', E'{"name":"messages.messages.create","description":"Create private messages through fixed Joomla administrator models.","risk":"high","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_messages"},{"action":"core.create","asset":"com_messages"}],"required_extensions":["com_messages"]}', E'{"verification":{"read_action":"messages.messages.get","operation":"create","primary_key":"message_id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'47496b56f6a571e6121908ce119888930d0243468551d46614e773e26503ab66', 0); -INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "seed_revision", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (325, 1, 202, E'messages.messages.delete.api', E'messages.messages.delete (API)', E'api', E'api.request', 7, 1, E'{"method":"DELETE","route":"/v1/messages/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"none","operation":"delete","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"messages.messages.get","mutation_rule":null,"snapshot_contract":"joomla.message-owned-record.v1"}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'61b7441efdda777437368d611fc39f2a3c44e7a8ee218c2fe9dfe31cf97d8096', 0); +INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "seed_revision", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (325, 1, 202, E'messages.messages.delete.api', E'messages.messages.delete (API)', E'api', E'api.request', 7, 1, E'{"method":"DELETE","route":"/v1/messages/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"none","operation":"delete","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"messages.messages.get","mutation_rule":null,"snapshot_contract":"joomla.message-owned-record.v1"}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'61b7441efdda777437368d611fc39f2a3c44e7a8ee218c2fe9dfe31cf97d8096', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "params", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "seed_revision", "seed_hash", "customized") VALUES (326, 1, 202, E'messages.messages.delete.cli', E'messages.messages.delete (CLI)', E'cli', E'native.core-entity', 8, 267, E'{"entity":{"id":"messages.messages","label":"private messages","component":"com_messages","listModel":"Messages","itemModel":"Message","readFields":["message_id","id","user_id_from","user_id_to","folder_id","date_time","state","priority","subject","message"],"writeFields":["user_id_to","folder_id","state","priority","subject","message"],"defaults":[],"modelState":[],"stateFilter":"filter.state","supportsState":false,"highRisk":true,"sensitiveFields":[],"primaryKey":"message_id","stateField":"published"},"operation":"delete"}', E'{"name":"messages.messages.delete","description":"Delete private messages through fixed Joomla administrator models.","risk":"high","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_messages"},{"action":"core.delete","asset":"com_messages"}],"required_extensions":["com_messages"]}', E'{"verification":{"read_action":"messages.messages.get","operation":"delete","primary_key":"message_id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'096879141fde89cd33aab15e0e8bb52d80dba481018c436bc95c7639ead48773', 0); -INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "seed_revision", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (327, 1, 203, E'messages.messages.get.api', E'messages.messages.get (API)', E'api', E'api.request', 10, 1, E'{"method":"GET","route":"/v1/messages/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"none","operation":"get","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"mutation_rule":null,"snapshot_contract":"joomla.message-owned-record.v1"}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'a47e2a59081678b40d797a494e41aabf9a574efe4e4722d9625534c2374faf4b', 0); +INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "seed_revision", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (327, 1, 203, E'messages.messages.get.api', E'messages.messages.get (API)', E'api', E'api.request', 10, 1, E'{"method":"GET","route":"/v1/messages/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"none","operation":"get","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"mutation_rule":null,"snapshot_contract":"joomla.message-owned-record.v1"}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'a47e2a59081678b40d797a494e41aabf9a574efe4e4722d9625534c2374faf4b', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "params", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "seed_revision", "seed_hash", "customized") VALUES (328, 1, 203, E'messages.messages.get.cli', E'messages.messages.get (CLI)', E'cli', E'native.core-entity', 11, 268, E'{"entity":{"id":"messages.messages","label":"private messages","component":"com_messages","listModel":"Messages","itemModel":"Message","readFields":["message_id","id","user_id_from","user_id_to","folder_id","date_time","state","priority","subject","message"],"writeFields":["user_id_to","folder_id","state","priority","subject","message"],"defaults":[],"modelState":[],"stateFilter":"filter.state","supportsState":false,"highRisk":true,"sensitiveFields":[],"primaryKey":"message_id","stateField":"published"},"operation":"get"}', E'{"name":"messages.messages.get","description":"Get private messages through fixed Joomla administrator models.","risk":"read","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_messages"}],"required_extensions":["com_messages"]}', E'{"verification":{}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'99c11444ab62ca91bcf6c8189cbd8bbf79a4646be4cee6d0e59a53e52370193e', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (329, 1, 204, E'messages.messages.list.api', E'messages.messages.list (API)', E'api', E'api.request', 13, 1, E'{"method":"GET","route":"/v1/messages","route_parameters":[],"paginated":true,"body_policy":"none","operation":"list","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"mutation_rule":null}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'084d004ed5e528678dfc099f607fb5243a1864b375ca126bb3394d26da98d658', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "params", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "seed_revision", "seed_hash", "customized") VALUES (330, 1, 204, E'messages.messages.list.cli', E'messages.messages.list (CLI)', E'cli', E'native.core-entity', 269, 270, E'{"entity":{"id":"messages.messages","label":"private messages","component":"com_messages","listModel":"Messages","itemModel":"Message","readFields":["message_id","id","user_id_from","user_id_to","folder_id","date_time","state","priority","subject","message"],"writeFields":["user_id_to","folder_id","state","priority","subject","message"],"defaults":[],"modelState":[],"stateFilter":"filter.state","supportsState":false,"highRisk":true,"sensitiveFields":[],"primaryKey":"message_id","stateField":"published"},"operation":"list"}', E'{"name":"messages.messages.list","description":"List private messages through fixed Joomla administrator models.","risk":"read","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_messages"}],"required_extensions":["com_messages"]}', E'{"verification":{}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'1da3e1e5182879d63c461fe4c99551f4cf71771fdc251416f26d4f4249925d9a', 0); -INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "seed_revision", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (331, 1, 205, E'messages.messages.update.api', E'messages.messages.update (API)', E'api', E'api.request', 271, 1, E'{"method":"PATCH","route":"/v1/messages/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"required","operation":"update","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"messages.messages.get","mutation_rule":null,"snapshot_contract":"joomla.message-owned-record.v1"}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'80a71ce429cbfc6954f8d67a293a4a728f22acdac4174840eeaad474282f6813', 0); +INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "seed_revision", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (331, 1, 205, E'messages.messages.update.api', E'messages.messages.update (API)', E'api', E'api.request', 271, 1, E'{"method":"PATCH","route":"/v1/messages/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"required","operation":"update","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"messages.messages.get","mutation_rule":null,"snapshot_contract":"joomla.message-owned-record.v1"}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/messages/src/Extension/Messages.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_messages","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/messages","com_messages"]}', 1, E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'80a71ce429cbfc6954f8d67a293a4a728f22acdac4174840eeaad474282f6813', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "params", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "seed_revision", "seed_hash", "customized") VALUES (332, 1, 205, E'messages.messages.update.cli', E'messages.messages.update (CLI)', E'cli', E'native.core-entity', 272, 273, E'{"entity":{"id":"messages.messages","label":"private messages","component":"com_messages","listModel":"Messages","itemModel":"Message","readFields":["message_id","id","user_id_from","user_id_to","folder_id","date_time","state","priority","subject","message"],"writeFields":["user_id_to","folder_id","state","priority","subject","message"],"defaults":[],"modelState":[],"stateFilter":"filter.state","supportsState":false,"highRisk":true,"sensitiveFields":[],"primaryKey":"message_id","stateField":"published"},"operation":"update"}', E'{"name":"messages.messages.update","description":"Update private messages through fixed Joomla administrator models.","risk":"high","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_messages"},{"action":"core.edit","asset":"com_messages"}],"required_extensions":["com_messages"]}', E'{"verification":{"read_action":"messages.messages.get","operation":"update","primary_key":"message_id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'f5acac62fc1103a275e40c3217cecb5437dc2c33f7d85fdbb1abbc0a58f5803c', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (333, 1, 206, E'modules.administrator-types.list.api', E'modules.administrator-types.list (API)', E'api', E'api.request', 49, 1, E'{"method":"GET","route":"/v1/modules/types/administrator","route_parameters":[],"paginated":false,"body_policy":"none","operation":"list","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"mutation_rule":null}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"Route"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'a245af06b145c70c39953d62cac6fa49e0b131db9ee2af1793d45f05c5bb3e08', 0); -INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "seed_revision", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (334, 1, 207, E'modules.administrator.create.api', E'modules.administrator.create (API)', E'api', E'api.request', 410, 1, E'{"method":"POST","route":"/v1/modules/administrator","route_parameters":[],"paginated":false,"body_policy":"required","operation":"create","body_defaults":{"client_id":1},"query_defaults":{},"preserve_fields":["params","assigned"],"derived_fields":["module_assignment"],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"modules.administrator.get","mutation_rule":null}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'20fb6d299eb279dac608c98c7e5a976805bb14f3cd51002b7da70314ea6c7c36', 0); +INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "seed_revision", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (334, 1, 207, E'modules.administrator.create.api', E'modules.administrator.create (API)', E'api', E'api.request', 412, 1, E'{"method":"POST","route":"/v1/modules/administrator","route_parameters":[],"paginated":false,"body_policy":"required","operation":"create","body_defaults":{"client_id":1},"query_defaults":{},"preserve_fields":["params","assigned"],"derived_fields":["module_assignment"],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"modules.administrator.get","mutation_rule":null}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2217f95665684e293ee598882493725504b81d4724c8f864a797ad3775143fd8', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "params", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "seed_revision", "seed_hash", "customized") VALUES (335, 1, 207, E'modules.administrator.create.cli', E'modules.administrator.create (CLI)', E'cli', E'native.core-entity', 275, 276, E'{"entity":{"id":"modules.administrator","label":"administrator modules","component":"com_modules","listModel":"Modules","itemModel":"Module","readFields":["id","title","note","content","ordering","position","checked_out","published","module","access","showtitle","params","client_id","language","assigned"],"writeFields":["title","note","content","ordering","position","published","module","access","showtitle","params","language","assigned"],"defaults":{"client_id":1},"modelState":{"client_id":1},"stateFilter":"filter.published","supportsState":true,"highRisk":true,"sensitiveFields":[],"primaryKey":"id","stateField":"published"},"operation":"create"}', E'{"name":"modules.administrator.create","description":"Create administrator modules through fixed Joomla administrator models.","risk":"high","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_modules"},{"action":"core.create","asset":"com_modules"}],"required_extensions":["com_modules"]}', E'{"verification":{"read_action":"modules.administrator.get","operation":"create","primary_key":"id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'd2ad1fb04daa4ab39f3e1f71f00872aae4bcadf57e839d45f051dea11afdf035', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (336, 1, 208, E'modules.administrator.delete.api', E'modules.administrator.delete (API)', E'api', E'api.request', 7, 1, E'{"method":"DELETE","route":"/v1/modules/administrator/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"none","operation":"delete","body_defaults":{"client_id":1},"query_defaults":{},"preserve_fields":["params","assigned"],"derived_fields":["module_assignment"],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"modules.administrator.get","mutation_rule":null}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'bff841b68a663aedbfc63ddf3f78ec4591ed1b063195eeef6203e403502ce8ce', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "params", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "seed_revision", "seed_hash", "customized") VALUES (337, 1, 208, E'modules.administrator.delete.cli', E'modules.administrator.delete (CLI)', E'cli', E'native.core-entity', 8, 277, E'{"entity":{"id":"modules.administrator","label":"administrator modules","component":"com_modules","listModel":"Modules","itemModel":"Module","readFields":["id","title","note","content","ordering","position","checked_out","published","module","access","showtitle","params","client_id","language","assigned"],"writeFields":["title","note","content","ordering","position","published","module","access","showtitle","params","language","assigned"],"defaults":{"client_id":1},"modelState":{"client_id":1},"stateFilter":"filter.published","supportsState":true,"highRisk":true,"sensitiveFields":[],"primaryKey":"id","stateField":"published"},"operation":"delete"}', E'{"name":"modules.administrator.delete","description":"Delete administrator modules through fixed Joomla administrator models.","risk":"high","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_modules"},{"action":"core.delete","asset":"com_modules"}],"required_extensions":["com_modules"]}', E'{"verification":{"read_action":"modules.administrator.get","operation":"delete","primary_key":"id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'b3cdcb2a48e7eed8d5297bb3446002af7597d15a95612264dfe28fff6c4a47ae', 0); @@ -1179,7 +1181,7 @@ INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (343, 1, 212, E'modules.administrator.update.api', E'modules.administrator.update (API)', E'api', E'api.request', 281, 1, E'{"method":"PATCH","route":"/v1/modules/administrator/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"required","operation":"update","body_defaults":{"client_id":1},"query_defaults":{},"preserve_fields":["params","assigned"],"derived_fields":["module_assignment"],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"modules.administrator.get","mutation_rule":null}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'cd1a1805dd5dfd75de43db2f6e8f55991956696f0d28138550af9f899a683889', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "params", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "seed_revision", "seed_hash", "customized") VALUES (344, 1, 212, E'modules.administrator.update.cli', E'modules.administrator.update (CLI)', E'cli', E'native.core-entity', 282, 283, E'{"entity":{"id":"modules.administrator","label":"administrator modules","component":"com_modules","listModel":"Modules","itemModel":"Module","readFields":["id","title","note","content","ordering","position","checked_out","published","module","access","showtitle","params","client_id","language","assigned"],"writeFields":["title","note","content","ordering","position","published","module","access","showtitle","params","language","assigned"],"defaults":{"client_id":1},"modelState":{"client_id":1},"stateFilter":"filter.published","supportsState":true,"highRisk":true,"sensitiveFields":[],"primaryKey":"id","stateField":"published"},"operation":"update"}', E'{"name":"modules.administrator.update","description":"Update administrator modules through fixed Joomla administrator models.","risk":"high","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_modules"},{"action":"core.edit","asset":"com_modules"}],"required_extensions":["com_modules"]}', E'{"verification":{"read_action":"modules.administrator.get","operation":"update","primary_key":"id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'4bdea5e7fe478d2720be8c7e46cb219dbb647e9f0e734f73682eb64befd73353', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (345, 1, 213, E'modules.site-types.list.api', E'modules.site-types.list (API)', E'api', E'api.request', 49, 1, E'{"method":"GET","route":"/v1/modules/types/site","route_parameters":[],"paginated":false,"body_policy":"none","operation":"list","body_defaults":[],"query_defaults":{},"preserve_fields":[],"derived_fields":[],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"mutation_rule":null}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"Route"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'c803328202d7306d91bc9b1763b6e7714b0a5a175c1660c3fa0ffdba9e69b567', 0); -INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "seed_revision", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (346, 1, 214, E'modules.site.create.api', E'modules.site.create (API)', E'api', E'api.request', 409, 1, E'{"method":"POST","route":"/v1/modules/site","route_parameters":[],"paginated":false,"body_policy":"required","operation":"create","body_defaults":{"client_id":0},"query_defaults":{},"preserve_fields":["params","assigned"],"derived_fields":["module_assignment"],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"modules.site.get","mutation_rule":null}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'c56d24856391ea2a2705cab8abe6419c55201a4597d1fa1a67002842ab655efa', 0); +INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "seed_revision", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (346, 1, 214, E'modules.site.create.api', E'modules.site.create (API)', E'api', E'api.request', 411, 1, E'{"method":"POST","route":"/v1/modules/site","route_parameters":[],"paginated":false,"body_policy":"required","operation":"create","body_defaults":{"client_id":0},"query_defaults":{},"preserve_fields":["params","assigned"],"derived_fields":["module_assignment"],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"modules.site.get","mutation_rule":null}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'65a2a4db22147683c65ef3fdda4571c630e12d61f8533d66091143f9d4914cb6', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "params", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "seed_revision", "seed_hash", "customized") VALUES (347, 1, 214, E'modules.site.create.cli', E'modules.site.create (CLI)', E'cli', E'native.core-entity', 275, 285, E'{"entity":{"id":"modules.site","label":"site modules","component":"com_modules","listModel":"Modules","itemModel":"Module","readFields":["id","title","note","content","ordering","position","checked_out","published","module","access","showtitle","params","client_id","language","assigned"],"writeFields":["title","note","content","ordering","position","published","module","access","showtitle","params","language","assigned"],"defaults":{"client_id":0},"modelState":{"client_id":0},"stateFilter":"filter.published","supportsState":true,"highRisk":false,"sensitiveFields":[],"primaryKey":"id","stateField":"published"},"operation":"create"}', E'{"name":"modules.site.create","description":"Create site modules through fixed Joomla administrator models.","risk":"write","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_modules"},{"action":"core.create","asset":"com_modules"}],"required_extensions":["com_modules"]}', E'{"verification":{"read_action":"modules.site.get","operation":"create","primary_key":"id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'582133a6475842a9d0dceda85f9b4dc1e76f8d3b3c24780b2e3befb0c3bad082', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "published", "asset_id", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (348, 1, 215, E'modules.site.delete.api', E'modules.site.delete (API)', E'api', E'api.request', 7, 1, E'{"method":"DELETE","route":"/v1/modules/site/:id","route_parameters":[{"name":"id","kind":"positive-integer","required":true,"maximumLength":16}],"paginated":false,"body_policy":"none","operation":"delete","body_defaults":{"client_id":0},"query_defaults":{},"preserve_fields":["params","assigned"],"derived_fields":["module_assignment"],"authentication":"joomla-api-token","response_shape":"json-api","source_gate":null,"read_action":"modules.site.get","mutation_rule":null}', E'{"source":{"repository":"joomla/joomla-cms","branch":"6.1-dev","commit":"071afb7ad305c02983a653ccfc301b5c8360264b","path":"plugins/webservices/modules/src/Extension/Modules.php","registration":"createCRUDRoutes"},"acl":{"apiLogin":"core.login.api","component":"com_modules","enforcement":"joomla-controller","resourceScoped":true,"actionHints":{"read":["core.manage"],"list":["core.manage"],"get":["core.manage"],"create":["core.create"],"update":["core.edit","core.edit.own"],"delete":["core.delete"]}},"required_extensions":["webservices/modules","com_modules"]}', 1, 0, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'7ab4df28ef85708cdfedfcc71a26a8cca2a73ae0e3926a83ca02aa4577e0b7d7', 0); INSERT INTO "#__joomengine_mcp_binding" ("id", "provider_id", "action_id", "name", "title", "track", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "params", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "seed_revision", "seed_hash", "customized") VALUES (349, 1, 215, E'modules.site.delete.cli', E'modules.site.delete (CLI)', E'cli', E'native.core-entity', 8, 286, E'{"entity":{"id":"modules.site","label":"site modules","component":"com_modules","listModel":"Modules","itemModel":"Module","readFields":["id","title","note","content","ordering","position","checked_out","published","module","access","showtitle","params","client_id","language","assigned"],"writeFields":["title","note","content","ordering","position","published","module","access","showtitle","params","language","assigned"],"defaults":{"client_id":0},"modelState":{"client_id":0},"stateFilter":"filter.published","supportsState":true,"highRisk":false,"sensitiveFields":[],"primaryKey":"id","stateField":"published"},"operation":"delete"}', E'{"name":"modules.site.delete","description":"Delete site modules through fixed Joomla administrator models.","risk":"high","drivers":["cli-companion"],"joomla":{"min":"6.1.0","canary":"7.0.0"},"acl":[{"action":"core.manage","asset":"com_modules"},{"action":"core.delete","asset":"com_modules"}],"required_extensions":["com_modules"]}', E'{"verification":{"read_action":"modules.site.get","operation":"delete","primary_key":"id","state_field":"published"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'f66e29b108a5ae97252cd030dd31358874e2fb561d1f32cb3d0d49dd4d193737', 0); @@ -1342,8 +1344,8 @@ INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "des INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (2, 1, E'joomla_capabilities', E'Inspect Joomla capabilities', E'Returns the source-backed core action catalogue and capabilities enabled for one configured site.', E'catalog.capabilities', 390, NULL, E'{}', E'{"name":"joomla_capabilities","title":"Inspect Joomla capabilities","description":"Returns the source-backed core action catalogue and capabilities enabled for one configured site.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'c6a51fb0dfadd74b1baf317ce0dd0b744f62f8649ec127c555c3105d92d3473d', 0); INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (3, 1, E'joomla_actions_search', E'Search enabled Joomla actions', E'Searches the source-backed semantic action catalogue, intersected with the selected site’s enabled toolsets. Writes are omitted unless includeWrites is explicitly true.', E'catalog.search', 391, NULL, E'{}', E'{"name":"joomla_actions_search","title":"Search enabled Joomla actions","description":"Searches the source-backed semantic action catalogue, intersected with the selected site’s enabled toolsets. Writes are omitted unless includeWrites is explicitly true.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'89dc50880678050e3c41ba688cad9a6c63d09c162459969ed7211ec9a69dd53c', 0); INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (4, 1, E'joomla_action_describe', E'Describe one Joomla action', E'Returns the exact source-backed route contract, typed input schema, native adapter metadata, and executable transports for one semantic action.', E'catalog.describe', 392, NULL, E'{}', E'{"name":"joomla_action_describe","title":"Describe one Joomla action","description":"Returns the exact source-backed route contract, typed input schema, native adapter metadata, and executable transports for one semantic action.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'59d6c081f9eb74df2b579afb8ffbbc63afbe5beb570988f0664fe158cc80d31e', 0); -INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (5, 1, E'joomla_action_read', E'Run an enabled Joomla read action', E'Executes one catalogue action by semantic identifier. Routes, origins, credentials, methods, and toolsets remain server controlled. Some source-defined GET operations perform Joomla bookkeeping; inspect the action descriptor before execution.', E'action.read', 393, NULL, E'{}', E'{"name":"joomla_action_read","title":"Run an enabled Joomla read action","description":"Executes one catalogue action by semantic identifier. Routes, origins, credentials, methods, and toolsets remain server controlled. Some source-defined GET operations perform Joomla bookkeeping; inspect the action descriptor before execution.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'c97872a08182eab21bf1f291ad0137069a8c3475b9fc1df4b16c49da292b3467', 0); -INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (6, 1, E'joomla_permission_request', E'Request permission for Joomla writes', E'Creates a principal-bound operator permission request for selected configured write or administration toolsets. Returns an exact acknowledgement phrase that must be shown to and supplied by the operator. It does not grant permission.', E'permission.request', 408, NULL, E'{}', E'{"name":"joomla_permission_request","title":"Request permission for Joomla writes","description":"Creates a principal-bound operator permission request for selected configured write or administration toolsets. Returns an exact acknowledgement phrase that must be shown to and supplied by the operator. It does not grant permission.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'8524b0c311c79cc9eaef270ab7cb86916ad1285e9aa92f3133d48e6e5778dd80', 0); +INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (5, 1, E'joomla_action_read', E'Run an enabled Joomla read action', E'Executes one catalogue action by semantic identifier. Routes, origins, credentials, methods, and toolsets remain server controlled. Some source-defined GET operations perform Joomla bookkeeping; inspect the action descriptor before execution.', E'action.read', 409, NULL, E'{}', E'{"name":"joomla_action_read","title":"Run an enabled Joomla read action","description":"Executes one catalogue action by semantic identifier. Routes, origins, credentials, methods, and toolsets remain server controlled. Some source-defined GET operations perform Joomla bookkeeping; inspect the action descriptor before execution.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'f57b4fc43398a38c2d965ec05a547defa84f066004276a5fd51204e3aa3b33c3', 0); +INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (6, 1, E'joomla_permission_request', E'Request permission for Joomla writes', E'Creates a principal-bound operator permission request for selected configured write or administration toolsets. Returns an exact acknowledgement phrase that must be shown to and supplied by the operator. It does not grant permission.', E'permission.request', 408, NULL, E'{}', E'{"name":"joomla_permission_request","title":"Request permission for Joomla writes","description":"Creates a principal-bound operator permission request for selected configured write or administration toolsets. Returns an exact acknowledgement phrase that must be shown to and supplied by the operator. It does not grant permission.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'8524b0c311c79cc9eaef270ab7cb86916ad1285e9aa92f3133d48e6e5778dd80', 0); INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (7, 1, E'joomla_permission_approve', E'Approve a requested Joomla permission', E'Validates the exact operator-supplied acknowledgement for a pending request and creates a principal-bound grant. The grant cannot exceed configured site toolsets, inbound OAuth scopes, or Joomla ACL.', E'permission.approve', 395, NULL, E'{}', E'{"name":"joomla_permission_approve","title":"Approve a requested Joomla permission","description":"Validates the exact operator-supplied acknowledgement for a pending request and creates a principal-bound grant. The grant cannot exceed configured site toolsets, inbound OAuth scopes, or Joomla ACL.","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'786d37b1b8fa3029c9238991684eac92b27fcf85cf92b8b563a40fe207198c9a', 0); INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (8, 1, E'joomla_permissions_list', E'List active Joomla permissions', E'Lists only the authenticated principal’s active grants, their site, toolsets, duration, expiry, and remaining use count. No acknowledgement phrase or principal identity is returned.', E'permission.list', 389, NULL, E'{}', E'{"name":"joomla_permissions_list","title":"List active Joomla permissions","description":"Lists only the authenticated principal’s active grants, their site, toolsets, duration, expiry, and remaining use count. No acknowledgement phrase or principal identity is returned.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'daf5cfa6301c6cebdc81d96a66333c4219a91d186fd62ff6dccce2b75fe0c7ff', 0); INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (9, 1, E'joomla_permission_revoke', E'Revoke a Joomla permission', E'Immediately revokes one active permission grant owned by the authenticated principal.', E'permission.revoke', 396, NULL, E'{}', E'{"name":"joomla_permission_revoke","title":"Revoke a Joomla permission","description":"Immediately revokes one active permission grant owned by the authenticated principal.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'4e32af78cee62da806cf6c97e8b8846ae9445503c5706de1a1a806a556d1931b', 0); @@ -1357,17 +1359,17 @@ INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "des INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (17, 1, E'joomla_cli_targets', E'Map Joomla CLI commands to fixed MCP actions', E'Returns the reviewed target, risk, implementation status, semantic action IDs, and installed native contract for stock Joomla CLI commands.', E'console.targets', 402, NULL, E'{"tracks":["cli"]}', E'{"name":"joomla_cli_targets","title":"Map Joomla CLI commands to fixed MCP actions","description":"Returns the reviewed target, risk, implementation status, semantic action IDs, and installed native contract for stock Joomla CLI commands.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'5fc57282a3299bd5c83fdd8f932847f9343015e6da57f8424379ff2f59af9e79', 0); INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (18, 1, E'joomla_companion_capabilities', E'Describe the Joomla PHP companion', E'Returns the installed companion version, effective ACL, and its strict structured-action catalogue.', E'console.capabilities', 390, NULL, E'{"tracks":["cli"]}', E'{"name":"joomla_companion_capabilities","title":"Describe the Joomla PHP companion","description":"Returns the installed companion version, effective ACL, and its strict structured-action catalogue.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'f4b16f513c92bcfa01685721fba0bb2f036226204f51fc22bf1f868814325ac5', 0); INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (19, 1, E'joomla_cli_inventory', E'Describe the installed Joomla CLI registry', E'Returns bounded structured metadata for installed Joomla commands and options through the companion. It does not execute the discovered commands.', E'console.inventory', 390, NULL, E'{"tracks":["cli"]}', E'{"name":"joomla_cli_inventory","title":"Describe the installed Joomla CLI registry","description":"Returns bounded structured metadata for installed Joomla commands and options through the companion. It does not execute the discovered commands.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'e70c70f6b408929919511baf4256fbd6ff141f4e3c363e0a342c3f356f17b604', 0); -INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (20, 1, E'joomla_companion_action_read', E'Run a Joomla-native companion read action', E'Runs one fixed Joomla-native structured read action through JSON stdin. No Joomla command, shell, path, URL, or credential is accepted.', E'action.read', 403, NULL, E'{"tracks":["cli"],"transport":"cli"}', E'{"name":"joomla_companion_action_read","title":"Run a Joomla-native companion read action","description":"Runs one fixed Joomla-native structured read action through JSON stdin. No Joomla command, shell, path, URL, or credential is accepted.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'4d0ad1be7d4261ac5fad1dd32a0e480a1866a8581cf241d523da657ef6de9817', 0); +INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (20, 1, E'joomla_companion_action_read', E'Run a Joomla-native companion read action', E'Runs one fixed Joomla-native structured read action through JSON stdin. No Joomla command, shell, path, URL, or credential is accepted.', E'action.read', 410, NULL, E'{"tracks":["cli"],"transport":"cli"}', E'{"name":"joomla_companion_action_read","title":"Run a Joomla-native companion read action","description":"Runs one fixed Joomla-native structured read action through JSON stdin. No Joomla command, shell, path, URL, or credential is accepted.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'cffba457e204f5f19cd46495c77b6804ae142db56569fd8dd86c20868b28ac4d', 0); INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (21, 1, E'joomla_content_article_create_plan', E'Plan a Joomla article creation', E'Validates a flat Joomla article form and returns a short-lived confirmation token without performing the write.', E'action.plan', 404, NULL, E'{"action":"content.articles.create","input_from":"arguments"}', E'{"name":"joomla_content_article_create_plan","title":"Plan a Joomla article creation","description":"Validates a flat Joomla article form and returns a short-lived confirmation token without performing the write.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'a251bddec8c268a0b1bd7017ccd7c54c84341b49b57c9664f1aa58f9a1c182ad', 0); INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (22, 1, E'joomla_content_article_update_plan', E'Plan a Joomla article update', E'Validates an allowlisted partial article update and returns a short-lived confirmation token.', E'action.plan', 405, NULL, E'{"action":"content.articles.update","input_from":"arguments"}', E'{"name":"joomla_content_article_update_plan","title":"Plan a Joomla article update","description":"Validates an allowlisted partial article update and returns a short-lived confirmation token.","annotations":{"readOnlyHint":false,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'2cc6b076723dc6c5707fa660c2184a2d205f179860049ff4e42afb2d602506da', 0); INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (23, 1, E'joomla_content_article_delete_plan', E'Plan a Joomla article deletion', E'Plans resource-model deletion for one article and returns a short-lived confirmation token.', E'action.plan', 406, NULL, E'{"action":"content.articles.delete","input_from":"arguments"}', E'{"name":"joomla_content_article_delete_plan","title":"Plan a Joomla article deletion","description":"Plans resource-model deletion for one article and returns a short-lived confirmation token.","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'6794059449b00fd5b700137f1df0bad1dd48d03a6d49c2bd06514e9e54261888', 0); INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_revision", "seed_hash", "customized") VALUES (24, 1, E'joomla_write_apply', E'Apply one planned Joomla write', E'Consumes a short-lived, signed, one-time confirmation token and applies exactly the previously validated operation.', E'action.apply', 407, NULL, E'{}', E'{"name":"joomla_write_apply","title":"Apply one planned Joomla write","description":"Consumes a short-lived, signed, one-time confirmation token and applies exactly the previously validated operation.","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'2cff50f4f6b440da3c684f9995a77efad32e1a36', E'af708f0c51387c14ab0572bd77b18f4c360dc3585fc7304ae0ae05f90ef38685', 0); -INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (25, 1, E'joomla_jobs_list', E'List owned jobs', E'List durable jobs belonging to the current authenticated principal. Status includes known cancellation, completion and uncertain outcomes.', E'job.list', 411, 1, E'{}', E'{"name":"joomla_jobs_list","title":"List owned jobs","description":"List durable jobs belonging to the current authenticated principal. Status includes known cancellation, completion and uncertain outcomes.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'7395ef732a01cc75e158634613b96a931ee7353e2d9d80c27c3a538e528b697a', 0); -INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (26, 1, E'joomla_job_status', E'Inspect an owned job', E'Read the current durable state and retained result of an owned job. An expired worker lease is reported as uncertain; a disconnected request is not proof of rollback.', E'job.status', 412, 1, E'{}', E'{"name":"joomla_job_status","title":"Inspect an owned job","description":"Read the current durable state and retained result of an owned job. An expired worker lease is reported as uncertain; a disconnected request is not proof of rollback.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'0698a762b00e4a519c1c1464c0a4068d513eb704dca480d4ee28deadf2a5fc43', 0); -INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (27, 1, E'joomla_job_cancel', E'Cancel an owned job', E'Request cancellation of an owned job. Queued work is cancelled before execution; running work may have partial effects. Inspect the returned state and reconcile uncertain outcomes.', E'job.cancel', 412, 1, E'{}', E'{"name":"joomla_job_cancel","title":"Cancel an owned job","description":"Request cancellation of an owned job. Queued work is cancelled before execution; running work may have partial effects. Inspect the returned state and reconcile uncertain outcomes.","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'911f92ddcc571a7af996c3c0177cbf3af3c4a62e7dbb660803d6a80feab0032a', 0); -INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (28, 1, E'joomla_job_redispatch', E'Redispatch an unstarted job', E'Redispatch only a never-started queued job using its existing approved execution claim. Running, cancelled and uncertain jobs cannot be replayed.', E'job.redispatch', 412, 1, E'{}', E'{"name":"joomla_job_redispatch","title":"Redispatch an unstarted job","description":"Redispatch only a never-started queued job using its existing approved execution claim. Running, cancelled and uncertain jobs cannot be replayed.","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'490f9f518e87b7529cff2082eb024cf877f4384059072e256330ea23062f987a', 0); -INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (29, 1, E'joomla_job_artifacts', E'List owned job artifacts', E'List immutable retained artifact metadata for an owned job. The server returns opaque artifact identifiers, never selectable filesystem paths.', E'job.artifacts', 412, 1, E'{}', E'{"name":"joomla_job_artifacts","title":"List owned job artifacts","description":"List immutable retained artifact metadata for an owned job. The server returns opaque artifact identifiers, never selectable filesystem paths.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'e0a2fb614aab6af1f86c9c1dc827032e626ece4172edf82115c0e37a46cf2fda', 0); -INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (30, 1, E'joomla_job_artifact_read', E'Read an owned artifact chunk', E'Read and verify a bounded base64-encoded chunk of a retained owned artifact. Use the returned offsets and hashes to assemble the file; no local or remote path input is accepted.', E'job.artifact.read', 413, 1, E'{}', E'{"name":"joomla_job_artifact_read","title":"Read an owned artifact chunk","description":"Read and verify a bounded base64-encoded chunk of a retained owned artifact. Use the returned offsets and hashes to assemble the file; no local or remote path input is accepted.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'8ce7e9b05ffad0d9aef126b8063c06be324faa825c2faa70265db587c728ad93', 0); +INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (25, 1, E'joomla_jobs_list', E'List owned jobs', E'List durable jobs belonging to the current authenticated principal. Status includes known cancellation, completion and uncertain outcomes.', E'job.list', 413, 1, E'{}', E'{"name":"joomla_jobs_list","title":"List owned jobs","description":"List durable jobs belonging to the current authenticated principal. Status includes known cancellation, completion and uncertain outcomes.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'39a0dcca02fe5fa9f11378a190f85e5642ce97ac8f9963d345f9d6c36680ffcf', 0); +INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (26, 1, E'joomla_job_status', E'Inspect an owned job', E'Read the current durable state and retained result of an owned job. An expired worker lease is reported as uncertain; a disconnected request is not proof of rollback.', E'job.status', 414, 1, E'{}', E'{"name":"joomla_job_status","title":"Inspect an owned job","description":"Read the current durable state and retained result of an owned job. An expired worker lease is reported as uncertain; a disconnected request is not proof of rollback.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'a55b11a0897352872ee04eb46884d3710c96fe31bb287079fc31615caf2bfab1', 0); +INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (27, 1, E'joomla_job_cancel', E'Cancel an owned job', E'Request cancellation of an owned job. Queued work is cancelled before execution; running work may have partial effects. Inspect the returned state and reconcile uncertain outcomes.', E'job.cancel', 414, 1, E'{}', E'{"name":"joomla_job_cancel","title":"Cancel an owned job","description":"Request cancellation of an owned job. Queued work is cancelled before execution; running work may have partial effects. Inspect the returned state and reconcile uncertain outcomes.","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'b5c44d8938d400ab7190dc2e3cce9e7f673b25a2ba1a5a632579051bcd88d54d', 0); +INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (28, 1, E'joomla_job_redispatch', E'Redispatch an unstarted job', E'Redispatch only a never-started queued job using its existing approved execution claim. Running, cancelled and uncertain jobs cannot be replayed.', E'job.redispatch', 414, 1, E'{}', E'{"name":"joomla_job_redispatch","title":"Redispatch an unstarted job","description":"Redispatch only a never-started queued job using its existing approved execution claim. Running, cancelled and uncertain jobs cannot be replayed.","annotations":{"readOnlyHint":false,"destructiveHint":true,"idempotentHint":false,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'46d1a5dd2902850669093bfeaad5e31f2c7b8a7d6796a7948c4b9986fa9013ec', 0); +INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (29, 1, E'joomla_job_artifacts', E'List owned job artifacts', E'List immutable retained artifact metadata for an owned job. The server returns opaque artifact identifiers, never selectable filesystem paths.', E'job.artifacts', 414, 1, E'{}', E'{"name":"joomla_job_artifacts","title":"List owned job artifacts","description":"List immutable retained artifact metadata for an owned job. The server returns opaque artifact identifiers, never selectable filesystem paths.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'e39a2637af0636d020d420a674a8c02a6958dd3e90e8a405f46c85d86631e70b', 0); +INSERT INTO "#__joomengine_mcp_tool" ("id", "provider_id", "name", "title", "description", "handler", "input_schema_id", "output_schema_id", "configuration", "definition", "seed_revision", "asset_id", "published", "access", "ordering", "checked_out", "checked_out_time", "created", "created_by", "modified", "modified_by", "version", "params", "seed_hash", "customized") VALUES (30, 1, E'joomla_job_artifact_read', E'Read an owned artifact chunk', E'Read and verify a bounded base64-encoded chunk of a retained owned artifact. Use the returned offsets and hashes to assemble the file; no local or remote path input is accepted.', E'job.artifact.read', 415, 1, E'{}', E'{"name":"joomla_job_artifact_read","title":"Read an owned artifact chunk","description":"Read and verify a bounded base64-encoded chunk of a retained owned artifact. Use the returned offsets and hashes to assemble the file; no local or remote path input is accepted.","annotations":{"readOnlyHint":true,"destructiveHint":false,"idempotentHint":true,"openWorldHint":false},"execution":{"taskSupport":"forbidden"}}', E'cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03', 0, 1, 1, 0, NULL, NULL, E'2026-09-17 00:00:00', 0, NULL, 0, 1, E'{}', E'ca08e219ef0bc569115777d4d205c45316d101ad40ab9927bca39b5eb6327137', 0); SELECT setval(pg_get_serial_sequence(E'#__joomengine_mcp_tool', 'id'), (SELECT MAX("id") FROM "#__joomengine_mcp_tool"), true); CREATE TABLE IF NOT EXISTS "#__joomengine_mcp_resource" ( diff --git a/admin/src/Console/Runtime.php b/admin/src/Console/Runtime.php index 3fa1314..0d6755a 100644 --- a/admin/src/Console/Runtime.php +++ b/admin/src/Console/Runtime.php @@ -77,8 +77,6 @@ public function executeCommand(string $operation, InputInterface $input, OutputI return $this->write(['protocol' => 'joomla-mcp/1', 'ok' => false, 'error' => ['code' => 'INVALID_FORMAT', 'message' => 'Unsupported command framing.']]); } - $this->runtime->catalogue()->refresh(); - if ($operation === 'jcb-sync') { if ($this->synchronizeJcb === null) @@ -89,6 +87,8 @@ public function executeCommand(string $operation, InputInterface $input, OutputI return $this->write(['protocol' => 'joomla-mcp/1', 'ok' => true, 'catalogue' => ($this->synchronizeJcb)()]); } + $this->runtime->catalogue()->refresh(); + if ($operation === 'describe') { return $this->write($this->runtime->tools()->companion()); diff --git a/admin/src/Controller/OperationsController.php b/admin/src/Controller/OperationsController.php index 192f2ca..a22793e 100644 --- a/admin/src/Controller/OperationsController.php +++ b/admin/src/Controller/OperationsController.php @@ -78,7 +78,7 @@ public function synchronizeJcb(): void $user = $this->app->getIdentity(); if ($this->runtime === null || !$user->authorise('core.manage', 'com_joomengine_mcp') - || !$user->authorise('core.admin', 'com_joomengine_mcp') || !$user->authorise('core.admin', 'com_componentbuilder')) + || !$user->authorise('core.admin', 'com_joomengine_mcp')) { throw new \RuntimeException('Native administration permission is required to synchronize JCB definitions.', 403); } diff --git a/admin/src/Handler/ApiRequestBuilder.php b/admin/src/Handler/ApiRequestBuilder.php index cd0fb75..288de59 100644 --- a/admin/src/Handler/ApiRequestBuilder.php +++ b/admin/src/Handler/ApiRequestBuilder.php @@ -9,6 +9,7 @@ namespace VDM\Component\JoomEngineMcp\Administrator\Handler; +use stdClass; use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; use VDM\Component\JoomEngineMcp\Administrator\Service\Json; use VDM\Component\JoomEngineMcp\Administrator\Service\TemplateStyleInheritance; @@ -65,7 +66,7 @@ public function build(array $arguments, array $configuration, array $current = [ } } - if (str_contains($route, ':') || str_contains($route, '..')) + if (str_contains($route, ':') || preg_match('/(?:\A|\/)\.{1,2}(?:\/|\z)/D', $route) === 1) { throw new OperationException('INVALID_INPUT', 'The resolved Joomla API route is unsafe or incomplete.'); } @@ -108,6 +109,12 @@ public function build(array $arguments, array $configuration, array $current = [ if (!empty($configuration['native_filter']) && array_key_exists('filter', $arguments)) { $filter = $arguments['filter']; + + if ($filter instanceof stdClass) + { + $filter = get_object_vars($filter); + } + if (!is_array($filter) || ($filter !== [] && array_is_list($filter)) || count($filter) > 32) { throw new OperationException('INVALID_INPUT', 'A bounded native filter object is required.'); @@ -115,14 +122,28 @@ public function build(array $arguments, array $configuration, array $current = [ foreach ($filter as $key => $value) { - if (!is_string($key) || preg_match('/\A[A-Za-z][A-Za-z0-9_]{0,63}\z/D', $key) !== 1 - || !is_scalar($value) || strlen((string) $value) > 2048 - || (is_float($value) && !is_finite($value))) + if (!is_string($key) || preg_match('/\A[A-Za-z][A-Za-z0-9_]{0,63}\z/D', $key) !== 1) { - throw new OperationException('INVALID_INPUT', 'A native filter needs bounded scalar values and literal field names.'); + throw new OperationException('INVALID_INPUT', 'A native filter needs literal field names.'); } - $query['filter[' . $key . ']'] = is_bool($value) ? (int) $value : $value; + $multiple = is_array($value); + + if ($multiple && (($configuration['native_filter_arrays'] ?? false) !== true + || $value === [] || !array_is_list($value) || count($value) > 64)) + { + throw new OperationException('INVALID_INPUT', 'A non-empty bounded native filter list must be declared by its binding.'); + } + + foreach ($multiple ? $value : [$value] as $index => $item) + { + if (!is_scalar($item) || strlen((string) $item) > 2048 || (is_float($item) && !is_finite($item))) + { + throw new OperationException('INVALID_INPUT', 'A native filter needs bounded scalar values.'); + } + + $query['filter[' . $key . ']' . ($multiple ? '[' . $index . ']' : '')] = is_bool($item) ? (int) $item : $item; + } } } @@ -141,6 +162,11 @@ public function build(array $arguments, array $configuration, array $current = [ { $body = $arguments['data']; + if ($body instanceof stdClass) + { + $body = get_object_vars($body); + } + if (!is_array($body) || $body === [] || array_is_list($body)) { throw new OperationException('INVALID_INPUT', 'A non-empty Joomla form object is required.'); @@ -267,8 +293,21 @@ private function encodeParameter(array $parameter, mixed $value): string return implode('/', array_map('rawurlencode', $parts)); } + // A registered alternate key remains one literal segment. Pre-encoded + // input is refused so a router cannot decode it into another path. + if ($kind === 'unique-key') + { + if ($value === '.' || $value === '..' || preg_match('/[\/\\\\%?#\x00-\x1f\x7f]/', $value) === 1) + { + throw new OperationException('INVALID_INPUT', 'The unique resource key contains unsafe syntax.'); + } + + return rawurlencode($value); + } + $pattern = match ($kind) { + 'guid' => '/\A[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}\z/D', 'component-name' => '/\Acom_[A-Za-z0-9_]+\z/D', 'language-code' => '/\A[a-z]{2,3}-[A-Z]{2}\z/D', 'override-constant' => '/\A[A-Z][A-Z0-9_]*\z/D', diff --git a/admin/src/Installer/SeedUpdater.php b/admin/src/Installer/SeedUpdater.php index a771ee8..48debcf 100644 --- a/admin/src/Installer/SeedUpdater.php +++ b/admin/src/Installer/SeedUpdater.php @@ -114,7 +114,8 @@ public function apply(array $seed): array $managedProviders[(int) $current['id']] = true; } - if ((int) $current['customized'] !== 0 || !hash_equals($current['seed_hash'], self::hash($entity, $current))) + if ((int) $current['customized'] !== 0 || !hash_equals($current['seed_hash'], self::hash($entity, $current)) + || $this->replacesOwnedSchema($current, $record)) { $counts['preserved']++; continue; @@ -173,6 +174,41 @@ public function apply(array $seed): array }); } + /** + * Preserve an effective administrator policy when a seed replaces its schema. + * + * A schema-only edit owns its dependants' existing validation relationship, + * even when those tool, action, prompt or binding rows are otherwise pristine. + * Keeping only the old schema row would silently bypass its restrictions. + * Output policy references are retained by the same rule. Missing schemas + * likewise remain unavailable rather than being bypassed. + * + * @param array $current Installed definition. + * @param array $record Proposed remapped seed definition. + * @return bool A proposed schema reference would discard an owned policy. + * @since 1.0.6 + */ + private function replacesOwnedSchema(array $current, array $record): bool + { + foreach (['input_schema_id', 'output_schema_id'] as $field) + { + if (empty($current[$field]) || (int) $current[$field] === (int) ($record[$field] ?? 0)) + { + continue; + } + + $schema = $this->store->one('schema', ['id' => (int) $current[$field]]); + + if ($schema === null || (int) $schema['customized'] !== 0 || $schema['seed_revision'] === '' + || !hash_equals($schema['seed_hash'], self::hash('schema', $schema))) + { + return true; + } + } + + return false; + } + /** @param string $entity Fixed definition type. @param array $record Current values. @return string Seed ownership hash, portable across database scalar types. @since 0.1.0 */ public static function hash(string $entity, array $record): string { diff --git a/admin/src/Jcb/ApiRegistry.php b/admin/src/Jcb/ApiRegistry.php index 4a7fe48..31e10ff 100644 --- a/admin/src/Jcb/ApiRegistry.php +++ b/admin/src/Jcb/ApiRegistry.php @@ -15,7 +15,7 @@ /** - * Inventory JCB routes after enabled webservices plugins register with Joomla. + * Inventory reviewed component routes after native webservices registration. * Component defaults establish ownership; path names never imply ownership. * * @since 0.1.0 @@ -26,12 +26,24 @@ final class ApiRegistry private ApiRouter $router; /** @var ?array Observed plugin provenance, required when supplied by the installed inventory. @since 0.1.1 */ private ?array $owners; + /** @var string[] Explicit installed component scope; Joomla core uses its existing catalogue. @since 0.1.2 */ + private array $components; - /** @param ApiRouter $router Router after onBeforeApiRoute. @param ?array $owners Observed native registration provenance. @since 0.1.0 */ - public function __construct(ApiRouter $router, ?array $owners = null) + /** @param ApiRouter $router Router after onBeforeApiRoute. @param ?array $owners Observed native registration provenance. @param string[] $components Reviewed installed components. @since 0.1.0 */ + public function __construct(ApiRouter $router, ?array $owners = null, array $components = ['com_componentbuilder']) { $this->router = $router; $this->owners = $owners; + $this->components = array_values(array_unique($components)); + + foreach ($this->components as $component) + { + if (!is_string($component) || preg_match('/\Acom_[A-Za-z][A-Za-z0-9_]*\z/D', $component) !== 1) + { + throw new OperationException('JCB_INVENTORY_INVALID', 'The installed API component scope contains an invalid extension name.'); + } + } + sort($this->components, SORT_STRING); } /** @return array Exact registered methods, paths, defaults and variables. @since 0.1.0 */ @@ -39,13 +51,16 @@ public function inventory(): array { $routes = []; $unsupported = []; + $unsupportedComponents = []; $registered = []; foreach ($this->router->getRoutes() as $route) { $defaults = $route->getDefaults(); - if (($defaults['component'] ?? '') !== 'com_componentbuilder') + $component = $defaults['component'] ?? ''; + + if (!in_array($component, $this->components, true)) { continue; } @@ -57,6 +72,7 @@ public function inventory(): array || preg_match('/\A\/v[1-9][0-9]*(?:\/(?:[A-Za-z0-9_-]+|:[A-Za-z][A-Za-z0-9_]*))+\/?\z/D', $path) !== 1) { $unsupported[$path] = 'A specialized controller or route requires a reviewed adapter.'; + $unsupportedComponents[$path] = $component; continue; } @@ -73,6 +89,7 @@ public function inventory(): array if ($definition['required_extensions'] === []) { $unsupported[$key] = 'The native route has no observed owning plugin; synchronize through its registration event.'; + $unsupportedComponents[$key] = $component; continue; } } @@ -88,6 +105,7 @@ public function inventory(): array if ($reason !== null) { $unsupported[$key] = $reason; + $unsupportedComponents[$key] = $component; continue; } @@ -97,9 +115,12 @@ public function inventory(): array ksort($routes, SORT_STRING); ksort($unsupported, SORT_STRING); + ksort($unsupportedComponents, SORT_STRING); return ['routes' => array_values($routes), 'unsupported' => $unsupported, - 'fingerprint' => hash('sha256', Json::canonical(['routes' => $routes, 'unsupported' => $unsupported]))]; + 'components' => $this->components, 'unsupported_components' => $unsupportedComponents, + 'fingerprint' => hash('sha256', Json::canonical(['routes' => $routes, 'unsupported' => $unsupported, + 'components' => $this->components, 'unsupported_components' => $unsupportedComponents]))]; } /** @@ -111,6 +132,18 @@ public function inventory(): array */ public static function unsupportedReason(array $route): ?string { + if (!is_string($route['method'] ?? null) || !is_string($route['controller'] ?? null) + || preg_match('/\A[a-zA-Z][a-zA-Z0-9_]*\.[a-zA-Z][a-zA-Z0-9_]*\z/D', $route['controller']) !== 1 + || !is_string($route['route'] ?? null) + || preg_match('/\A\/v[1-9][0-9]*(?:\/(?:[A-Za-z0-9_-]+|:[A-Za-z][A-Za-z0-9_]*))+\/?\z/D', $route['route']) !== 1 + || !is_array($route['variables'] ?? null) || !array_is_list($route['variables']) + || !is_array($route['rules'] ?? null) || !is_array($route['defaults'] ?? null) + || !is_string($route['defaults']['component'] ?? null) + || preg_match('/\Acom_[A-Za-z][A-Za-z0-9_]*\z/D', $route['defaults']['component']) !== 1) + { + return 'The native route contract lacks valid method, controller, component or path metadata.'; + } + $task = substr($route['controller'], strrpos($route['controller'], '.') + 1); $operations = ['GET' => ['displayList', 'displayItem'], 'POST' => ['add'], 'PATCH' => ['edit'], 'PUT' => ['edit'], 'DELETE' => ['delete']]; @@ -128,23 +161,25 @@ public static function unsupportedReason(array $route): ?string foreach ($variables as $variable) { + if (!is_string($variable) || preg_match('/\A[A-Za-z][A-Za-z0-9_]*\z/D', $variable) !== 1) + { + return 'A native route variable must be a literal named identifier.'; + } + if (in_array($variable, ['data', 'offset', 'limit', 'filter', 'ordering', 'direction', 'etag', 'site'], true)) { return 'A route variable collides with a reserved action input.'; } - $rule = $route['rules'][$variable] ?? ''; - if (!in_array($rule, ['', '(\\d+)', '\\d+', '[0-9]+', '([0-9]+)', '[A-Za-z0-9][A-Za-z0-9._-]*'], true)) + if (self::parameterKind($route, $variable) === null) { return 'A specialized route-variable rule requires a reviewed encoder.'; } } - if (in_array($task, ['displayItem', 'edit', 'delete'], true) - && (!in_array('id', $variables, true) - || !in_array($route['rules']['id'] ?? '', ['(\\d+)', '\\d+', '[0-9]+', '([0-9]+)'], true))) + if (in_array($task, ['displayItem', 'edit', 'delete'], true) && self::identity($route) === null) { - return 'Native item operations require an explicit numeric id route variable.'; + return 'Native item operations require a reviewed numeric id, GUID or registered unique-key route variable.'; } foreach ($route['defaults'] as $name => $value) @@ -165,4 +200,58 @@ public static function unsupportedReason(array $route): ?string return null; } + /** @param array $route Actual registration. @param string $variable Route variable. @return ?string Reviewed encoder kind. @since 0.1.2 */ + public static function parameterKind(array $route, string $variable): ?string + { + $rule = $route['rules'][$variable] ?? ''; + + if (in_array($rule, ['(\\d+)', '\\d+', '[0-9]+', '([0-9]+)'], true)) + { + return 'positive-integer'; + } + + if ($variable === 'guid' && in_array($rule, ['([0-9a-fA-F-]{36})', '[0-9a-fA-F-]{36}', + '([0-9a-f-]{36})', '[0-9a-f-]{36}', '([0-9A-F-]{36})', '[0-9A-F-]{36}'], true)) + { + return 'guid'; + } + + if ($variable !== 'id' && in_array($rule, ['([^/]+)', '[^/]+'], true)) + { + return 'unique-key'; + } + + return in_array($rule, ['', '[A-Za-z0-9][A-Za-z0-9._-]*'], true) ? 'adapter-id' : null; + } + + /** + * Resolve only identities declared by the actual item route; no alias is invented. + * + * @param array $route Native item registration. + * @return ?array Variable, stored field and typed encoder, or null. + * @since 0.1.2 + */ + public static function identity(array $route): ?array + { + $identity = null; + + foreach ($route['variables'] as $variable) + { + $kind = self::parameterKind($route, $variable); + + if (($variable === 'id' && $kind === 'positive-integer') || ($variable === 'guid' && $kind === 'guid') + || ($kind === 'unique-key' && str_ends_with(rtrim($route['route'], '/'), '/' . $variable . '/:' . $variable))) + { + if ($identity !== null) + { + return null; + } + + $identity = ['name' => $variable, 'field' => $variable, 'kind' => $kind]; + } + } + + return $identity; + } + } diff --git a/admin/src/Jcb/CatalogueBuilder.php b/admin/src/Jcb/CatalogueBuilder.php index 9355b97..98c5756 100644 --- a/admin/src/Jcb/CatalogueBuilder.php +++ b/admin/src/Jcb/CatalogueBuilder.php @@ -26,30 +26,87 @@ final class CatalogueBuilder /** * @param array $commands Native CommandRegistry inventory. * @param array $api Native ApiRegistry inventory. - * @return array Portable complete graph for the JCB-owned providers only. + * @return array Portable complete graph for explicitly inventoried component providers. * @since 0.1.0 */ public function build(array $commands, array $api): array { $rows = array_fill_keys(array_keys(Structure::definitions()), []); - $source = hash('sha256', Json::canonical(['commands' => $commands, 'api' => $api])); + $source = Json::canonicalHash(['commands' => $commands, 'api' => $api]); $add = static function (string $entity, array $record) use (&$rows): int { $id = count($rows[$entity]) + 1; $rows[$entity][] = ['id' => $id] + $record; return $id; }; - $provider = $add('provider', ['name' => 'jcb.installed', 'title' => 'Joomla Component Builder', - 'extension' => 'com_componentbuilder', 'description' => 'Contracts observed in this installed JCB API and console registry.', - 'definition' => Json::encode(['minimumJoomla' => '6.1.0', 'maximumJoomlaExclusive' => '7.0.0', - 'inventory' => $source, 'commandCount' => count($commands['commands'] ?? []), 'routeCount' => count($api['routes'] ?? []), - 'unsupportedCommands' => (object) ($commands['unsupported'] ?? []), - 'unsupportedRoutes' => (object) ($api['unsupported'] ?? [])])]); - $schema = static function (string $name, array $document) use ($add, $provider): int + $routes = $api['routes'] ?? []; + + if (array_key_exists('components', $api)) { - return $add('schema', ['provider_id' => $provider, 'name' => $name, 'title' => $name, 'document' => Json::encode($document)]); + foreach ($routes as $route) + { + if (!in_array($route['defaults']['component'] ?? '', $api['components'], true)) + { + throw new OperationException('JCB_INVENTORY_INVALID', 'An API route is outside the explicitly synchronized component scope.'); + } + } + } + + $components = array_unique(array_merge($api['components'] ?? [], array_map( + static fn (array $route): string => $route['defaults']['component'] ?? '', $routes))); + sort($components, SORT_STRING); + $provider = null; + $output = null; + $providers = []; + $outputs = []; + $jcbRoutes = array_filter($routes, static fn (array $route): bool => ($route['defaults']['component'] ?? '') === 'com_componentbuilder'); + + // A partial component synchronization must not manage or retire JCB's + // existing records unless that provider is part of the observed scope. + if (!array_key_exists('components', $api) || in_array('com_componentbuilder', $components, true) + || self::hasCommandScope($commands)) + { + $provider = $add('provider', ['name' => 'jcb.installed', 'title' => 'Joomla Component Builder', + 'extension' => 'com_componentbuilder', 'description' => 'Contracts observed in this installed JCB API and console registry.', + 'definition' => Json::encode(['minimumJoomla' => '6.1.0', 'maximumJoomlaExclusive' => '7.0.0', + 'inventory' => $source, 'commandCount' => count($commands['commands'] ?? []), 'routeCount' => count($jcbRoutes), + 'unsupportedCommands' => (object) ($commands['unsupported'] ?? []), + 'unsupportedRoutes' => (object) ($api['unsupported'] ?? [])])]); + } + + $schema = static function (string $name, array $document, ?int $owner = null) use ($add, $provider): int + { + return $add('schema', ['provider_id' => $owner ?? $provider, 'name' => $name, 'title' => $name, 'document' => Json::encode($document)]); }; - $output = $schema('jcb.result', ['type' => 'object', 'additionalProperties' => true]); + if ($provider !== null) + { + $output = $schema('jcb.result', ['type' => 'object', 'additionalProperties' => true]); + $providers['com_componentbuilder'] = $provider; + $outputs['com_componentbuilder'] = $output; + } + + foreach ($components as $component) + { + if (preg_match('/\Acom_[A-Za-z][A-Za-z0-9_]*\z/D', $component) !== 1) + { + throw new OperationException('JCB_INVENTORY_INVALID', 'An API provider lacks a valid native component owner.'); + } + + if ($component === 'com_componentbuilder') + { + continue; + } + + $owned = array_filter($routes, static fn (array $route): bool => ($route['defaults']['component'] ?? '') === $component); + $diagnostics = array_filter($api['unsupported'] ?? [], static fn (string $key): bool => + ($api['unsupported_components'][$key] ?? null) === $component, ARRAY_FILTER_USE_KEY); + $prefix = 'jcb.component.' . substr($component, 4); + $providers[$component] = $add('provider', ['name' => $prefix . '.installed', 'title' => $component, + 'extension' => $component, 'description' => 'Native API contracts observed for this installed component.', + 'definition' => Json::encode(['minimumJoomla' => '6.1.0', 'maximumJoomlaExclusive' => '7.0.0', + 'inventory' => $source, 'routeCount' => count($owned), 'unsupportedRoutes' => (object) $diagnostics])]); + $outputs[$component] = $schema($prefix . '.result', ['type' => 'object', 'additionalProperties' => true], $providers[$component]); + } $permissions = [['action' => 'core.admin', 'asset' => 'com_componentbuilder']]; foreach ($commands['commands'] ?? [] as $command) @@ -104,7 +161,6 @@ public function build(array $commands, array $api): array 'definition' => Json::encode(['nativeCommand' => $name, 'unavailableReason' => $reason])]); } - $routes = $api['routes'] ?? []; foreach ($routes as $route) { if (ApiRegistry::unsupportedReason($route) !== null) @@ -114,6 +170,9 @@ public function build(array $commands, array $api): array $method = $route['method']; $task = substr($route['controller'], strrpos($route['controller'], '.') + 1); + $component = $route['defaults']['component']; + $routeProvider = $providers[$component]; + $routeOutput = $outputs[$component]; $name = self::routeName($route); $properties = []; $required = []; @@ -121,11 +180,17 @@ public function build(array $commands, array $api): array foreach ($route['variables'] as $variable) { - $integer = in_array($route['rules'][$variable] ?? '', ['(\\d+)', '\\d+', '[0-9]+', '([0-9]+)'], true); - $properties[$variable] = $integer ? ['type' => 'integer', 'minimum' => 1, 'maximum' => 9007199254740991] - : ['type' => 'string', 'minLength' => 1, 'maxLength' => 255, 'pattern' => '^[A-Za-z0-9][A-Za-z0-9._-]*$']; + $kind = ApiRegistry::parameterKind($route, $variable); + $properties[$variable] = match ($kind) { + 'positive-integer' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 9007199254740991], + 'guid' => ['type' => 'string', 'minLength' => 36, 'maxLength' => 36, + 'pattern' => '^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$'], + 'unique-key' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 255, + 'pattern' => '^(?!\\.{1,2}$)[^/\\\\%?#\\x00-\\x1f\\x7f]+$'], + default => ['type' => 'string', 'minLength' => 1, 'maxLength' => 255, 'pattern' => '^[A-Za-z0-9][A-Za-z0-9._-]*$'], + }; $required[] = $variable; - $parameters[] = ['name' => $variable, 'kind' => $integer ? 'positive-integer' : 'adapter-id']; + $parameters[] = ['name' => $variable, 'kind' => $kind]; } $list = $method === 'GET' && $task === 'displayList'; @@ -135,61 +200,78 @@ public function build(array $commands, array $api): array { $properties['offset'] = ['type' => 'integer', 'minimum' => 0, 'maximum' => 100000]; $properties['limit'] = ['type' => 'integer', 'minimum' => 1, 'maximum' => 500]; + $filterValue = ['type' => ['string', 'integer', 'number', 'boolean'], 'maxLength' => 2048]; $properties['filter'] = ['type' => 'object', 'maxProperties' => 32, 'propertyNames' => ['pattern' => '^[A-Za-z][A-Za-z0-9_]{0,63}$'], - 'additionalProperties' => ['type' => ['string', 'integer', 'number', 'boolean'], 'maxLength' => 2048], - 'description' => 'Native filter names and values accepted by this installed controller; unrecognized filters may be ignored by Joomla.']; + 'additionalProperties' => ['anyOf' => [$filterValue, ['type' => 'array', 'minItems' => 1, 'maxItems' => 64, 'items' => $filterValue]]], + 'description' => 'Native scalar or multiselect filter values accepted by this installed controller; unrecognized filters may be ignored by Joomla.']; $properties['ordering'] = ['type' => 'string', 'maxLength' => 190, 'pattern' => '^[A-Za-z][A-Za-z0-9_.]*$']; $properties['direction'] = ['type' => 'string', 'enum' => ['asc', 'desc']]; } if ($body) { - $properties['data'] = ['type' => 'object', 'minProperties' => 1, 'additionalProperties' => true]; + $properties['data'] = $route['form_contract']['schema'] ?? ['type' => 'object', 'minProperties' => 1, 'additionalProperties' => true]; $required[] = 'data'; } $input = $schema($name . '.input', ['type' => 'object', 'properties' => (object) $properties, - 'required' => $required, 'additionalProperties' => false]); + 'required' => $required, 'additionalProperties' => false], $routeProvider); $operation = match ($task) { 'displayList' => 'list', 'displayItem' => 'get', 'add' => 'create', 'edit' => 'update', 'delete' => 'delete', default => $task }; - $definition = ['nativeRoute' => $route, 'required_extensions' => $route['required_extensions'] ?? [], - 'required_permissions' => [['action' => 'core.manage', 'asset' => 'com_componentbuilder']]]; - $action = $add('action', ['provider_id' => $provider, 'name' => $name, 'title' => $route['controller'] . ' (' . $method . ')', - 'description' => 'Installed JCB API: ' . $method . ' ' . $route['route'], 'domain' => 'jcb', - 'toolset' => $method === 'GET' ? 'jcb.read' : 'jcb.write', 'effect' => $method === 'GET' ? 'read' : 'write', + $nativeRoute = $route; + unset($nativeRoute['form_contract']); + + if (isset($route['form_contract']['fingerprint'])) + { + $nativeRoute['form_contract_fingerprint'] = $route['form_contract']['fingerprint']; + } + + $definition = ['nativeRoute' => $nativeRoute, 'required_extensions' => $route['required_extensions'] ?? [], + 'required_permissions' => [['action' => 'core.manage', 'asset' => $component]]]; + $domain = $component === 'com_componentbuilder' ? 'jcb' : 'component_api'; + $action = $add('action', ['provider_id' => $routeProvider, 'name' => $name, 'title' => $route['controller'] . ' (' . $method . ')', + 'description' => 'Installed component API: ' . $method . ' ' . $route['route'], 'domain' => $domain, + 'toolset' => $domain . ($method === 'GET' ? '.read' : '.write'), 'effect' => $method === 'GET' ? 'read' : 'write', 'risk' => $method === 'GET' ? 'read' : ($method === 'DELETE' ? 'destructive' : 'write'), - 'input_schema_id' => $input, 'output_schema_id' => $output, 'definition' => Json::encode($definition)]); + 'input_schema_id' => $input, 'output_schema_id' => $routeOutput, 'definition' => Json::encode($definition)]); $config = ['method' => $method, 'route' => $route['route'], 'route_parameters' => $parameters, 'paginated' => $list, 'body_policy' => $body ? 'required' : 'none', 'operation' => $operation, 'body_defaults' => (object) ($body ? self::dataDefaults($route) : []), 'query_defaults' => (object) self::dataDefaults($route), 'authentication' => 'joomla-api-token', 'response_shape' => 'jsonapi', 'preserve_fields' => [], 'derived_fields' => []]; + $params = []; + + if (isset($route['form_contract'])) + { + $config['api_form'] = $route['form_contract']; + } if ($list) { $config['native_filter'] = true; + $config['native_filter_arrays'] = true; $config['query_map'] = ['ordering' => 'list[ordering]', 'direction' => 'list[direction]']; } if (in_array($operation, ['create', 'update', 'delete'], true)) { - $controller = substr($route['controller'], 0, (int) strrpos($route['controller'], '.')); + $read = self::readRoute($route, $routes, $operation); - foreach ($routes as $read) + if ($read !== null) { - if ($read['method'] === 'GET' && $read['controller'] === $controller . '.displayItem' - && in_array('id', $read['variables'], true) - && rtrim($read['route'], '/') === ($operation === 'create' ? rtrim($route['route'], '/') . '/:id' : rtrim($route['route'], '/')) - && Json::canonical(self::dataDefaults($read)) === Json::canonical(self::dataDefaults($route))) - { - $config['read_action'] = self::routeName($read); - break; - } + $config['read_action'] = self::routeName($read); + $identity = ApiRegistry::identity($read); + $writeIdentity = ApiRegistry::identity($route) ?? $identity; + $params['verification'] = ['read_action' => $config['read_action'], 'operation' => $operation, + 'primary_key' => $identity['field'], 'input_key' => $writeIdentity['name'], + 'read_input_key' => $identity['name'], 'identity_type' => match ($identity['kind']) { + 'positive-integer' => 'integer', 'guid' => 'guid', default => 'string', + }, 'state_field' => 'state']; } } - $add('binding', ['provider_id' => $provider, 'action_id' => $action, 'name' => $name . '.api', 'title' => $name, - 'input_schema_id' => $input, 'output_schema_id' => $output, 'track' => 'api', 'handler' => 'api.request', - 'configuration' => Json::encode($config), 'definition' => Json::encode($definition)]); + $add('binding', ['provider_id' => $routeProvider, 'action_id' => $action, 'name' => $name . '.api', 'title' => $name, + 'input_schema_id' => $input, 'output_schema_id' => $routeOutput, 'track' => 'api', 'handler' => 'api.request', + 'configuration' => Json::encode($config), 'definition' => Json::encode($definition), 'params' => Json::encode((object) $params)]); } foreach ($rows as $entity => &$records) @@ -219,6 +301,21 @@ public function build(array $commands, array $api): array return ['source' => $source, 'entities' => $rows]; } + /** + * Identify every command inventory that manages existing JCB provider records. + * Unsupported registrations and an authoritative empty scope can retire rows + * during synchronization, so they require the same native administration ACL. + * + * @param array $commands Observed native command inventory. + * @return bool Whether the graph manages JCB command ownership. + * @since 1.0.6 + */ + public static function hasCommandScope(array $commands): bool + { + return !empty($commands['commands']) || !empty($commands['unsupported']) + || ($commands['component'] ?? null) === 'com_componentbuilder'; + } + /** @param array $route Native registered defaults. @return array Fixed controller input without router metadata. @since 0.1.1 */ private static function dataDefaults(array $route): array { @@ -236,7 +333,56 @@ private static function dataDefaults(array $route): array /** @param array $route Actual registered API method/path. @return string Stable route identity. @since 0.1.0 */ private static function routeName(array $route): string { - return 'jcb.api.' . strtolower($route['controller']) . '.' . strtolower($route['method']) + $component = $route['defaults']['component']; + $prefix = $component === 'com_componentbuilder' ? 'jcb.api.' : 'jcb.api.' . substr($component, 4) . '.'; + + return $prefix . strtolower($route['controller']) . '.' . strtolower($route['method']) . '.' . substr(hash('sha256', $route['route']), 0, 12); } + + /** + * Match a mutation to an observed item read of the same component and fixed scope. + * + * @param array $write Actual mutation route. + * @param array $routes Installed supported registrations. + * @param string $operation Create, update or delete. + * @return ?array Matching independent item read, preferring numeric create readback. + * @since 0.1.2 + */ + private static function readRoute(array $write, array $routes, string $operation): ?array + { + $controller = substr($write['controller'], 0, (int) strrpos($write['controller'], '.')); + $candidates = []; + + foreach ($routes as $read) + { + $identity = ApiRegistry::identity($read); + + if ($read['method'] !== 'GET' || $read['controller'] !== $controller . '.displayItem' || $identity === null + || ($read['defaults']['component'] ?? '') !== ($write['defaults']['component'] ?? '') + || Json::canonical(self::dataDefaults($read)) !== Json::canonical(self::dataDefaults($write))) + { + continue; + } + + $path = rtrim($write['route'], '/'); + $expected = $operation === 'create' ? $path . ($identity['name'] === 'id' ? '/:id' + : '/' . $identity['name'] . '/:' . $identity['name']) : $path; + + if (rtrim($read['route'], '/') === $expected) + { + $candidates[] = $read; + } + } + + usort($candidates, static function (array $left, array $right): int + { + $rank = ['positive-integer' => 0, 'guid' => 1, 'unique-key' => 2]; + $order = ($rank[ApiRegistry::identity($left)['kind']] ?? 3) <=> ($rank[ApiRegistry::identity($right)['kind']] ?? 3); + + return $order !== 0 ? $order : strcmp(self::routeName($left), self::routeName($right)); + }); + + return $candidates[0] ?? null; + } } diff --git a/admin/src/Jcb/CatalogueSynchronizer.php b/admin/src/Jcb/CatalogueSynchronizer.php index b881705..f36e89c 100644 --- a/admin/src/Jcb/CatalogueSynchronizer.php +++ b/admin/src/Jcb/CatalogueSynchronizer.php @@ -46,11 +46,19 @@ public function __construct(StoreInterface $store, callable $assets) public function synchronize(array $commands, array $api, PrincipalInterface $principal): array { if (!$principal->isLocal() && (!$principal->authorise('core.admin', 'com_joomengine_mcp') - || !$principal->authorise('core.admin', 'com_componentbuilder'))) + || (CatalogueBuilder::hasCommandScope($commands) && !$principal->authorise('core.admin', 'com_componentbuilder')))) { throw new OperationException('JCB_CATALOGUE_DENIED', 'Catalogue synchronization requires component and JCB administration permission.'); } + foreach ($api['components'] ?? ['com_componentbuilder'] as $component) + { + if (!is_string($component) || (!$principal->isLocal() && !$principal->authorise('core.admin', $component))) + { + throw new OperationException('JCB_CATALOGUE_DENIED', 'Catalogue synchronization requires native administration permission for every selected component.'); + } + } + $seed = (new CatalogueBuilder())->build($commands, $api); $changes = (new SeedUpdater($this->store))->apply($seed); ($this->assets)(); diff --git a/admin/src/Jcb/FormContracts.php b/admin/src/Jcb/FormContracts.php new file mode 100644 index 0000000..78f6a14 --- /dev/null +++ b/admin/src/Jcb/FormContracts.php @@ -0,0 +1,844 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ +namespace VDM\Component\JoomEngineMcp\Administrator\Jcb; + + +use SimpleXMLElement; +use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; +use VDM\Component\JoomEngineMcp\Administrator\Service\Json; + + +/** + * Describe installed administrator forms explicitly bound by generated API code. + * + * Inspection never invokes an installed controller, model, rule or source field. + * Native validation and runtime ACL/form changes remain authoritative. Metadata + * is materialized during catalogue synchronization, not inferred from a request. + * + * @since 1.0.6 + */ +final class FormContracts +{ + /** @var string Confined installed administrator component root. @since 1.0.6 */ + private string $administratorRoot; + /** @var string Confined installed API component root. @since 1.0.6 */ + private string $apiRoot; + /** @var array Contract source provenance. @since 1.0.6 */ + private array $sources = []; + /** @var int Bounded number of XML fields traversed per contract. @since 1.0.6 */ + private int $fieldCount = 0; + + /** + * @param string $administratorRoot Installed administrator component directory. + * @param string $apiRoot Installed API component directory. + * @since 1.0.6 + */ + public function __construct(string $administratorRoot, string $apiRoot) + { + $this->administratorRoot = realpath($administratorRoot) ?: ''; + $this->apiRoot = realpath($apiRoot) ?: ''; + } + + /** + * Bind a route to exactly one literal native model/form mapping. + * + * Unknown/dynamic mappings return null rather than guessing a singular name. + * Once a form binding is established, unsafe XML is an explicit diagnostic. + * + * @param array $route Actual installed router registration. + * @return ?array Schema, native descriptors, verification and provenance. + * @throws OperationException When a bound form is malformed or unsafe. + * @since 1.0.6 + */ + public function forRoute(array $route): ?array + { + $this->sources = []; + $this->fieldCount = 0; + $controller = explode('.', (string) ($route['controller'] ?? ''))[0]; + + if ($this->administratorRoot === '' || $this->apiRoot === '' + || preg_match('/\A[A-Za-z][A-Za-z0-9_]*\z/D', $controller) !== 1) + { + return null; + } + + $source = $this->read($this->apiRoot, 'src/Controller/' . ucfirst($controller) . 'Controller.php', 'api'); + + if ($source === null) + { + return null; + } + + $tokens = self::tokens($source); + $mapping = self::method($tokens, 'getModel'); + $models = self::assignedLiterals($mapping, '$name'); + $prefixes = self::assignedLiterals($mapping, '$prefix'); + + if (array_diff($prefixes, ['', 'Administrator']) !== []) + { + return null; + } + + // A literal parent model call is used by explicitly implemented adapters. + foreach (self::callArguments($mapping, 'getModel', 'parent') as $arguments) + { + $name = self::literal($arguments[0] ?? []); + $prefix = self::literal($arguments[1] ?? []); + + if ($prefix !== null && $prefix !== '' && $prefix !== 'Administrator') + { + return null; + } + + if ($name !== null && $prefix === 'Administrator') + { + $models[] = $name; + } + } + + $forms = []; + + foreach (array_unique($models) as $model) + { + if (preg_match('/\A[A-Za-z][A-Za-z0-9_]*\z/D', $model) !== 1) + { + continue; + } + + $modelSource = $this->read($this->administratorRoot, 'src/Model/' . ucfirst($model) . 'Model.php', 'administrator'); + + if ($modelSource === null) + { + continue; + } + + $modelTokens = self::tokens($modelSource); + + foreach (self::callArguments(self::method($modelTokens, 'getForm'), 'loadForm', '$this') as $arguments) + { + $form = self::literal($arguments[1] ?? []); + + if ($form !== null && preg_match('/\A[A-Za-z][A-Za-z0-9_-]*\z/D', $form) === 1) + { + $forms[$model . ':' . $form] = ['model' => $model, 'form' => $form, 'tokens' => $modelTokens]; + } + } + } + + if (count($forms) !== 1) + { + return null; + } + + $binding = array_values($forms)[0]; + $xml = $this->xml('forms/' . $binding['form'] . '.xml'); + $modelTokens = $binding['tokens']; + $dynamicRequired = self::changesRequired($modelTokens); + $tree = $this->form($xml, $dynamicRequired, ['forms/' . $binding['form'] . '.xml'], 0); + $decodedFields = self::decodedFields($modelTokens); + + foreach ($tree['fields'] as $name => $descriptor) + { + if (strtolower($descriptor['native_type'] ?? '') === 'subform' && in_array($name, $decodedFields, true)) + { + $tree['verification'][$name]['representation'] = 'json'; + } + } + + $requestOnly = $tree['request_only']; + $generation = []; + + foreach ($tree['fields'] as $name => &$descriptor) + { + if (($descriptor['validation'] ?? '') === 'guid' && $name === 'guid') + { + $serverGenerated = self::serverGuid($modelTokens, $name); + $descriptor['identity'] = ['kind' => 'guid', 'server_generated' => $serverGenerated]; + + if ($descriptor['required'] && ($descriptor['default'] ?? '') === '' && !$serverGenerated + && !$dynamicRequired && empty($descriptor['showon'])) + { + $generation[$name] = ['kind' => 'guid', 'on' => 'create', 'format' => 'uuid-v4']; + } + } + } + unset($descriptor); + + // JCB's explicit validation control is transient; its use must be observed + // in validate(), not inferred merely from a field called not_required. + if (isset($tree['fields']['not_required']) && self::usesValidationControl($modelTokens)) + { + $requestOnly[] = 'not_required'; + $tree['fields']['not_required']['request_only'] = true; + $tree['fields']['not_required']['semantics'] = 'Comma-separated field names whose native required attribute is disabled and whose input is removed by model validation.'; + } + + $schema = $tree['schema']; + $schema['description'] = 'Installed native administrator form. Defaults, choices and conditional rules are described from XML; the API applies its native model, ACL and validation. Omitted PATCH fields remain omitted.'; + + // PATCH/PUT work with an existing record and must not require every field + // needed by a fresh form. The API owns its update/back-fill semantics. + if (($route['method'] ?? '') !== 'POST') + { + unset($schema['required']); + } + + ksort($this->sources, SORT_STRING); + $contract = ['schema' => $schema, 'fields' => $tree['fields'], + 'verification' => ['fields' => $tree['verification'], 'request_only' => array_values(array_unique($requestOnly))], + 'generation' => $generation, 'native_required_adjustments' => $dynamicRequired, + 'provenance' => ['model' => $binding['model'], 'form' => $binding['form'], 'sources' => array_values($this->sources)]]; + $contract['fingerprint'] = hash('sha256', Json::canonical($contract)); + + return $contract; + } + + /** + * Traverse form/fieldset/fields groups while preserving nested data names. + * + * @param SimpleXMLElement $xml Native form or form group. + * @param bool $dynamicRequired Whether the model changes required attributes. + * @param string[] $chain Confined XML reference chain. + * @param int $depth Bounded traversal depth. + * @return array Native form tree and equivalent data schema. + * @since 1.0.6 + */ + private function form(SimpleXMLElement $xml, bool $dynamicRequired, array $chain, int $depth): array + { + if ($depth > 16) + { + throw new OperationException('JCB_FORM_CONTRACT_INVALID', 'The bound native form exceeds the supported nesting depth.'); + } + + $properties = []; + $fields = []; + $verification = []; + $required = []; + $requestOnly = []; + + foreach ($xml->children() as $child) + { + $tag = $child->getName(); + + if ($tag === 'fieldset' || $tag === 'fields') + { + $nested = $this->form($child, $dynamicRequired, $chain, $depth + 1); + $group = $tag === 'fields' ? (string) $child['name'] : ''; + + if ($group !== '' && self::name($group)) + { + $properties[$group] = $nested['schema']; + $fields[$group] = ['native_type' => 'fields', 'fields' => $nested['fields']]; + $verification[$group] = ['representation' => 'object', 'properties' => $nested['verification']]; + } + else + { + $properties += (array) $nested['schema']['properties']; + $fields += $nested['fields']; + $verification += $nested['verification']; + $required = array_merge($required, $nested['schema']['required'] ?? []); + $requestOnly = array_merge($requestOnly, $nested['request_only']); + } + + continue; + } + + if ($tag !== 'field') + { + continue; + } + + $name = (string) $child['name']; + $type = strtolower((string) $child['type']); + + if (!self::name($name) || in_array($type, ['note', 'spacer'], true)) + { + continue; + } + + if (++$this->fieldCount > 4096) + { + throw new OperationException('JCB_FORM_CONTRACT_INVALID', 'The bound native form exceeds the supported field count.'); + } + + $entry = $this->field($child, $dynamicRequired, $chain, $depth + 1); + $properties[$name] = $entry['schema']; + $fields[$name] = $entry['descriptor']; + + if ($entry['verification'] !== []) + { + $verification[$name] = $entry['verification']; + } + + if ($entry['descriptor']['request_only']) + { + $requestOnly[] = $name; + } + + if ($entry['descriptor']['required'] && !$dynamicRequired + && !isset($entry['descriptor']['default']) && empty($entry['descriptor']['showon']) + && !$entry['descriptor']['readonly'] && !$entry['descriptor']['disabled']) + { + $required[] = $name; + } + } + + $schema = ['type' => 'object', 'properties' => $properties === [] ? (object) [] : $properties, 'additionalProperties' => true]; + + if ($required !== []) + { + $schema['required'] = array_values(array_unique($required)); + } + + return ['schema' => $schema, 'fields' => $fields, 'verification' => $verification, 'request_only' => $requestOnly]; + } + + /** + * Describe a literal field without turning custom PHP into executable policy. + * + * @param SimpleXMLElement $xml Native field XML. + * @param bool $dynamicRequired Whether native required attributes may change. + * @param string[] $chain Confined XML reference chain. + * @param int $depth Bounded traversal depth. + * @return array Schema, descriptor and source-backed representation rules. + * @since 1.0.6 + */ + private function field(SimpleXMLElement $xml, bool $dynamicRequired, array $chain, int $depth): array + { + $nativeType = (string) $xml['type']; + $type = strtolower($nativeType); + $filter = strtolower((string) $xml['filter']); + $validate = (string) $xml['validate']; + $descriptor = ['native_type' => $nativeType, 'filter' => (string) $xml['filter'], 'validation' => $validate, + 'required' => self::truth((string) $xml['required']), 'readonly' => self::truth((string) $xml['readonly']), + 'disabled' => self::truth((string) $xml['disabled']), 'multiple' => self::truth((string) $xml['multiple']), + 'request_only' => $filter === 'unset', 'native_required_adjustments' => $dynamicRequired]; + + foreach (['label', 'description', 'hint', 'showon', 'maxlength', 'min', 'max', 'step', 'pattern'] as $attribute) + { + if (isset($xml[$attribute])) + { + $descriptor[$attribute] = (string) $xml[$attribute]; + } + } + + if (isset($xml['default'])) + { + $descriptor['default'] = (string) $xml['default']; + } + + $choices = []; + + foreach ($xml->option as $option) + { + $choices[] = ['value' => (string) $option['value'], 'label' => trim((string) $option)]; + } + + if ($choices !== []) + { + $descriptor['choices'] = $choices; + } + + // Modal selection fields declare their own identity key in XML. Retain + // it as reference metadata; do not issue SQL or invent a target record. + $referenceKey = (string) $xml['sql_title_key']; + + if ($type === 'modalselect' && self::name($referenceKey)) + { + $descriptor['relationship'] = ['option_value_properties' => [$referenceKey], + 'native_reference' => ['table' => (string) $xml['sql_title_table'], + 'title_column' => (string) $xml['sql_title_column'], 'value_column' => $referenceKey, + 'data_key_name' => (string) $xml['data-key-name']], + 'authority' => 'Literal native modal-selection XML; resolve actual relationship identifiers through authorized API records.']; + } + + $verification = []; + $schema = []; + + // Filters, rather than field names or appearance, establish coercion. + if (in_array($filter, ['int', 'integer', 'intval', 'uint'], true) || $type === 'integer') + { + $schema = ['type' => ['integer', 'number', 'string', 'boolean', 'null']]; + $verification = ['representation' => 'integer']; + } + elseif (in_array($filter, ['bool', 'boolean'], true)) + { + $schema = ['type' => ['boolean', 'integer', 'string', 'null']]; + $verification = ['representation' => 'boolean']; + } + elseif (in_array($type, ['text', 'textarea', 'editor', 'password', 'hidden', 'email', 'url', 'calendar'], true) + && $filter !== 'raw') + { + $schema = ['type' => ['string', 'number', 'boolean', 'null']]; + } + elseif ($type === 'editor' || $type === 'textarea') + { + $schema = ['type' => ['string', 'null']]; + $descriptor['inert_source'] = $filter === 'raw'; + } + + if ($type === 'subform') + { + $subform = null; + + if (isset($xml->form)) + { + $subform = $xml->form; + } + elseif (isset($xml['formsource'])) + { + $reference = (string) $xml['formsource']; + + if (pathinfo($reference, PATHINFO_EXTENSION) === '') + { + $reference .= '.xml'; + } + + $reference = str_starts_with($reference, 'forms/') ? $reference : 'forms/' . $reference; + + if (in_array($reference, $chain, true)) + { + throw new OperationException('JCB_FORM_CONTRACT_INVALID', 'The bound native subform contains a cyclic form reference.'); + } + + $chain[] = $reference; + $subform = $this->xml($reference); + } + + if ($subform !== null) + { + $nested = $this->form($subform, $dynamicRequired, $chain, $depth + 1); + $descriptor['fields'] = $nested['fields']; + $row = $nested['schema']; + $many = $descriptor['multiple'] || self::truth((string) $subform['repeat']); + $schema = $many ? ['anyOf' => [ + ['type' => 'array', 'items' => $row], + ['type' => 'object', 'additionalProperties' => $row], + ]] : $row; + $verification = ['representation' => $many ? 'array' : 'object', + 'properties' => $many ? [] : $nested['verification'], + 'items' => ['representation' => 'object', 'properties' => $nested['verification']]]; + + if ($many) + { + // Joomla repeatable subforms also accept named row objects. The + // verification contract keeps row keys and does not reorder them. + $verification['additionalProperties'] = $verification['items']; + } + } + } + + if ($descriptor['multiple'] && $type !== 'subform') + { + $schema = ['type' => ['array', 'null'], 'items' => $schema === [] ? (object) [] : $schema]; + $verification = $verification === [] ? [] : ['representation' => 'array', 'items' => $verification]; + } + + foreach ([['src/Rule/', $validate, 'Rule.php', 'validation_source'], ['src/Field/', $nativeType, 'Field.php', 'field_source']] as $source) + { + if ($source[1] !== '' && preg_match('/\A[A-Za-z][A-Za-z0-9_]*\z/D', $source[1]) === 1) + { + $path = $source[0] . ucfirst($source[1]) . $source[2]; + $code = $this->read($this->administratorRoot, $path, 'administrator'); + + if ($code !== null) + { + $descriptor[$source[3]] = ['path' => 'administrator/' . $path, 'sha256' => hash('sha256', $code), + 'authority' => 'Native installed code; custom rules and relationship choices are evaluated by the API.']; + + if ($source[3] === 'field_source') + { + $options = self::optionValues(self::tokens($code)); + + if ($options !== []) + { + $descriptor['relationship'] = ['option_value_properties' => $options, + 'authority' => 'Literal select.option value properties from the native field; resolve actual values through authorized API records.']; + } + } + } + } + } + + $parts = ['Native field type: ' . $type . '.']; + + if ($filter !== '') + { + $parts[] = 'Native filter: ' . (string) $xml['filter'] . '.'; + } + + if ($validate !== '') + { + $parts[] = 'Native validation rule: ' . $validate . '.'; + } + + if ($descriptor['required']) + { + $parts[] = $dynamicRequired || !empty($descriptor['showon']) + ? 'The form declares required; native runtime or conditional rules determine when it applies.' + : 'The form declares required.'; + } + + if (isset($descriptor['showon'])) + { + $parts[] = 'Native showon: ' . $descriptor['showon'] . '.'; + } + + if (isset($descriptor['default'])) + { + // SchemaValidator applies JSON Schema defaults to mutation input. Native + // XML defaults describe form initialization; silently injecting them into + // PATCH would overwrite unrelated existing fields and server controls. + $parts[] = 'Native XML default: ' . Json::encode($descriptor['default']) . '; described only, never inserted into an omitted API field.'; + } + + $schema['description'] = implode(' ', $parts); + + return ['schema' => $schema, 'descriptor' => $descriptor, 'verification' => $verification]; + } + + /** + * Parse confined XML with external entities and DTDs prohibited. + * + * @param string $relative Native form path below the administrator directory. + * @return SimpleXMLElement Safe parsed native form. + * @since 1.0.6 + */ + private function xml(string $relative): SimpleXMLElement + { + $bytes = $this->read($this->administratorRoot, $relative, 'administrator'); + + if ($bytes === null || preg_match('/getName() !== 'form') + { + throw new OperationException('JCB_FORM_CONTRACT_INVALID', 'The bound native form is not valid form XML.'); + } + + return $xml; + } + finally + { + libxml_clear_errors(); + libxml_use_internal_errors($previous); + } + } + + /** + * Read only regular files confined to their trusted installed component root. + * + * @param string $root Canonical installed root. + * @param string $relative Literal source path within the component. + * @param string $area Public relative provenance prefix. + * @return ?string Bounded source bytes, or null when unavailable. + * @since 1.0.6 + */ + private function read(string $root, string $relative, string $area): ?string + { + if ($root === '' || strlen($relative) > 512 + || preg_match('/\A(?:[A-Za-z0-9_-]+\/)*[A-Za-z0-9_.-]+\z/D', $relative) !== 1 + || in_array('..', explode('/', $relative), true)) + { + return null; + } + + $path = realpath($root . '/' . $relative); + + if ($path === false || !str_starts_with($path, $root . DIRECTORY_SEPARATOR) + || !is_file($path) || !is_readable($path) || filesize($path) > 2097152) + { + return null; + } + + $bytes = file_get_contents($path); + + if ($bytes === false) + { + return null; + } + + $this->sources[$area . '/' . $relative] = ['path' => $area . '/' . $relative, 'sha256' => hash('sha256', $bytes)]; + + return $bytes; + } + + /** @param string $source Inert PHP source. @return array Significant PHP tokens. @since 1.0.6 */ + private static function tokens(string $source): array + { + return array_values(array_filter(token_get_all($source), static fn (mixed $token): bool => !is_array($token) + || !in_array($token[0], [T_WHITESPACE, T_COMMENT, T_DOC_COMMENT, T_OPEN_TAG, T_CLOSE_TAG], true))); + } + + /** @param mixed $token PHP token. @return string Exact token text. @since 1.0.6 */ + private static function text(mixed $token): string + { + return is_array($token) ? $token[1] : (string) $token; + } + + /** + * Select a literal named method body without examining string/comment contents. + * + * @param array $tokens Significant source tokens. + * @param string $name Method name. + * @return array Method body tokens, or empty when ambiguous/absent. + * @since 1.0.6 + */ + private static function method(array $tokens, string $name): array + { + $found = []; + + foreach ($tokens as $index => $token) + { + if (!is_array($token) || $token[0] !== T_FUNCTION || self::text($tokens[$index + 1] ?? '') !== $name) + { + continue; + } + + while (isset($tokens[$index]) && self::text($tokens[$index]) !== '{' && self::text($tokens[$index]) !== ';') + { + $index++; + } + + if (self::text($tokens[$index] ?? '') !== '{') + { + continue; + } + + $body = []; + $depth = 1; + + while (isset($tokens[++$index]) && $depth > 0) + { + $text = self::text($tokens[$index]); + $depth += $text === '{' ? 1 : ($text === '}' ? -1 : 0); + + if ($depth > 0) + { + $body[] = $tokens[$index]; + } + } + + $found[] = $body; + } + + return count($found) === 1 ? $found[0] : []; + } + + /** @param array $tokens Source tokens. @param string $variable Assigned variable. @return string[] Literal values. @since 1.0.6 */ + private static function assignedLiterals(array $tokens, string $variable): array + { + $values = []; + + foreach ($tokens as $index => $token) + { + if (is_array($token) && $token[0] === T_VARIABLE && $token[1] === $variable + && self::text($tokens[$index + 1] ?? '') === '=' && self::text($tokens[$index + 3] ?? '') === ';') + { + $value = self::literal([$tokens[$index + 2] ?? '']); + + if ($value !== null) + { + $values[] = $value; + } + } + } + + return $values; + } + + /** + * Extract call arguments without evaluating PHP expressions. + * + * @param array $tokens Method body tokens. + * @param string $method Literal called method name. + * @param ?string $owner Optional exact object/class token owning the call. + * @return array> Tokenized argument lists. + * @since 1.0.6 + */ + private static function callArguments(array $tokens, string $method, ?string $owner = null): array + { + $calls = []; + + foreach ($tokens as $index => $token) + { + if (!is_array($token) || $token[0] !== T_STRING || $token[1] !== $method + || !in_array(self::text($tokens[$index - 1] ?? ''), ['->', '::'], true) + || ($owner !== null && self::text($tokens[$index - 2] ?? '') !== $owner) + || self::text($tokens[$index + 1] ?? '') !== '(') + { + continue; + } + + $arguments = [[]]; + $depth = 1; + $argument = 0; + $index++; + + while (isset($tokens[++$index]) && $depth > 0) + { + $text = self::text($tokens[$index]); + $depth += in_array($text, ['(', '[', '{'], true) ? 1 : (in_array($text, [')', ']', '}'], true) ? -1 : 0); + + if ($text === ',' && $depth === 1) + { + $arguments[++$argument] = []; + } + elseif ($depth > 0) + { + $arguments[$argument][] = $tokens[$index]; + } + } + + $calls[] = $arguments; + } + + return $calls; + } + + /** @param array $tokens One PHP expression. @return ?string Safe literal identifier. @since 1.0.6 */ + private static function literal(array $tokens): ?string + { + $token = $tokens[0] ?? null; + + if (count($tokens) !== 1 || !is_array($token) || $token[0] !== T_CONSTANT_ENCAPSED_STRING) + { + return null; + } + + $value = substr($token[1], 1, -1); + + return preg_match('/\A[A-Za-z0-9_.-]*\z/D', $value) === 1 ? $value : null; + } + + /** @param array $tokens Model source tokens. @return bool Native form required attributes can change. @since 1.0.6 */ + private static function changesRequired(array $tokens): bool + { + foreach (self::callArguments($tokens, 'setFieldAttribute') as $arguments) + { + if (self::literal($arguments[1] ?? []) === 'required') + { + return true; + } + } + + return false; + } + + /** @param array $tokens Model source tokens. @return bool Exact JCB validation control is consumed. @since 1.0.6 */ + private static function usesValidationControl(array $tokens): bool + { + $validate = self::method($tokens, 'validate'); + $control = false; + + foreach ($validate as $token) + { + $control = $control || self::literal([$token]) === 'not_required'; + } + + return $control && self::changesRequired($validate); + } + + /** @param array $tokens Native model tokens. @param string $field GUID field name. @return bool Native form sets a generated GUID. @since 1.0.6 */ + private static function serverGuid(array $tokens, string $field): bool + { + foreach (self::callArguments(self::method($tokens, 'getForm'), 'setValue') as $arguments) + { + if (self::literal($arguments[0] ?? []) !== $field) + { + continue; + } + + $value = array_map(self::text(...), $arguments[2] ?? []); + + if ($value === ['GuidHelper', '::', 'get', '(', ')']) + { + return true; + } + } + + return false; + } + + /** + * Observe field-specific native structured decoding without reading storage. + * + * @param array $tokens Native model tokens. + * @return string[] Literal item properties passed to JSON/Registry decoding. + * @since 1.0.6 + */ + private static function decodedFields(array $tokens): array + { + $properties = []; + $tokens = self::method($tokens, 'getItem'); + + foreach ($tokens as $index => $token) + { + if (is_array($token) && $token[0] === T_STRING && in_array($token[1], ['json_decode', 'loadString'], true) + && self::text($tokens[$index + 1] ?? '') === '(' + && self::text($tokens[$index + 2] ?? '') === '$item' + && self::text($tokens[$index + 3] ?? '') === '->') + { + $name = self::text($tokens[$index + 4] ?? ''); + + if (self::name($name)) + { + $properties[] = $name; + } + } + } + + return array_values(array_unique($properties)); + } + + /** + * Observe dynamic choice identity properties, never dynamic choice values. + * + * @param array $tokens Native field source tokens. + * @return string[] Source-backed option value properties, such as guid or id. + * @since 1.0.6 + */ + private static function optionValues(array $tokens): array + { + $properties = []; + + foreach (self::callArguments(self::method($tokens, 'getOptions'), '_') as $arguments) + { + $value = array_map(self::text(...), $arguments[1] ?? []); + + if (self::literal($arguments[0] ?? []) === 'select.option' && count($value) === 3 + && str_starts_with($value[0], '$') && $value[1] === '->' && self::name($value[2])) + { + $properties[] = $value[2]; + } + } + + return array_values(array_unique($properties)); + } + + /** @param string $value Native field/group name. @return bool Safe JSON property name. @since 1.0.6 */ + private static function name(string $value): bool + { + return preg_match('/\A[A-Za-z][A-Za-z0-9_]*\z/D', $value) === 1; + } + + /** @param string $value Native XML truth value. @return bool Native declaration is enabled. @since 1.0.6 */ + private static function truth(string $value): bool + { + return in_array(strtolower($value), ['true', '1', 'yes', 'required'], true); + } +} diff --git a/admin/src/Jcb/GeneratedApiInventory.php b/admin/src/Jcb/GeneratedApiInventory.php new file mode 100644 index 0000000..2dc41ce --- /dev/null +++ b/admin/src/Jcb/GeneratedApiInventory.php @@ -0,0 +1,119 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ +namespace VDM\Component\JoomEngineMcp\Administrator\Jcb; + + +use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; +use VDM\Component\JoomEngineMcp\Administrator\Service\Json; + + +/** + * Select installed extension-owned API contracts without shadowing Joomla core. + * Registration and native form evidence remain authoritative; names never invent routes. + * + * @since 1.0.6 + */ +final class GeneratedApiInventory +{ + /** + * Select enabled components outside Joomla's authoritative core identity list. + * Native uninstall protection and locking do not determine API capabilities. + * + * @param array $extensions Native extension registry observations. + * @param string[] $coreComponents Component elements from Joomla ExtensionHelper::getCoreExtensions(). + * @return string[] Stable component allowlist for the observed route registry. + * @since 1.0.6 + */ + public static function components(array $extensions, array $coreComponents): array + { + $components = []; + + foreach ($coreComponents as $component) + { + if (!is_string($component) || preg_match('/\Acom_[a-z][a-z0-9_]{0,95}\z/D', $component) !== 1) + { + throw new OperationException('JCB_INVENTORY_INVALID', 'The native core component identity list is invalid.'); + } + } + + foreach ($extensions as $extension) + { + $element = $extension['element'] ?? ''; + + if (($extension['type'] ?? '') === 'component' && (int) ($extension['enabled'] ?? 0) === 1 + && !in_array($element, $coreComponents, true) && $element !== 'com_joomengine_mcp' + && is_string($element) && preg_match('/\Acom_[a-z][a-z0-9_]{0,95}\z/D', $element) === 1) + { + $components[$element] = $element; + } + } + + ksort($components, SORT_STRING); + + return array_values($components); + } + + /** + * Attach native form evidence or explicit source-contract diagnostics. + * + * @param array $inventory Observed registered route inventory. + * @param string $administratorRoot Installed administrator component parent directory. + * @param string $apiRoot Installed API component parent directory. + * @return array Source-fingerprinted contracts, never fabricated executable rows. + * @since 1.0.6 + */ + public static function enrich(array $inventory, string $administratorRoot, string $apiRoot): array + { + $routes = []; + $forms = []; + $unsupported = $inventory['unsupported'] ?? []; + $owners = $inventory['unsupported_components'] ?? []; + + foreach ($inventory['routes'] ?? [] as $route) + { + try + { + $component = $route['defaults']['component']; + if (!is_string($component) || preg_match('/\Acom_[A-Za-z][A-Za-z0-9_]*\z/D', $component) !== 1) + { + throw new OperationException('JCB_FORM_CONTRACT_INVALID', 'The native form component identity is invalid.'); + } + $forms[$component] ??= new FormContracts($administratorRoot . '/' . $component, $apiRoot . '/' . $component); + $contract = $forms[$component]->forRoute($route); + + if ($contract !== null) + { + $route['form_contract'] = $contract; + } + else + { + $route['form_contract_status'] = 'Native form metadata unavailable; the endpoint remains the validation authority.'; + } + + $routes[] = $route; + } + catch (OperationException $error) + { + $key = $route['method'] . ' ' . $route['route']; + $unsupported[$key] = 'Native form metadata could not be safely synchronized: ' . $error->getMessage(); + $owners[$key] = $route['defaults']['component']; + } + } + + ksort($unsupported, SORT_STRING); + ksort($owners, SORT_STRING); + $inventory['routes'] = $routes; + $inventory['unsupported'] = $unsupported; + $inventory['unsupported_components'] = $owners; + $inventory['fingerprint'] = Json::canonicalHash(['routes' => $routes, + 'unsupported' => $unsupported, 'components' => $inventory['components'] ?? []]); + + return $inventory; + } +} diff --git a/admin/src/Jcb/InventoryTransport.php b/admin/src/Jcb/InventoryTransport.php new file mode 100644 index 0000000..f87e92a --- /dev/null +++ b/admin/src/Jcb/InventoryTransport.php @@ -0,0 +1,212 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ +namespace VDM\Component\JoomEngineMcp\Administrator\Jcb; + + +use stdClass; +use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; +use VDM\Component\JoomEngineMcp\Administrator\Service\Json; + + +/** + * Lossless bounded inventory IPC with shared, content-addressed native forms. + * + * Contract JSON strings preserve empty object/list shapes across the worker's + * associative envelope decoder. References never reach persisted bindings. + * + * @since 1.0.6 + */ +final class InventoryTransport +{ + /** @var string Explicit fixed-worker inventory encoding. @since 1.0.6 */ + public const FORMAT = 'joomengine-inventory/1'; + /** @var int Existing maximum worker response bytes. @since 1.0.6 */ + public const MAX_WIRE_BYTES = 8388608; + /** @var int Maximum expanded native inventory, before catalogue mutation. @since 1.0.6 */ + public const MAX_EXPANDED_BYTES = 67108864; + /** @var int Maximum complete native route inventory. @since 1.0.6 */ + public const MAX_ROUTES = 8192; + + /** + * Replace repeated complete contracts with exact canonical JSON references. + * + * @param array $result Actual observed command and API inventories. + * @return array Complete bounded worker representation. + * @since 1.0.6 + */ + public static function pack(array $result): array + { + self::inventory($result); + $fingerprint = Json::canonicalHash($result); + $contracts = []; + $expanded = 0; + + foreach ($result['api']['routes'] as &$route) + { + if (array_key_exists('form_contract_ref', $route)) + { + self::invalid('An observed route collides with the inventory reference field.'); + } + if (!array_key_exists('form_contract', $route)) + { + continue; + } + if (!is_array($route['form_contract'])) + { + self::invalid('The native form contract is invalid.'); + } + + $text = Json::canonical($route['form_contract']); + $reference = hash('sha256', $text); + $contracts[$reference] = $text; + $expanded += strlen($text); + self::expanded($expanded); + unset($route['form_contract']); + $route['form_contract_ref'] = $reference; + } + unset($route); + + $manifest = Json::canonical($result); + self::expanded($expanded + strlen($manifest)); + $packed = ['protocol' => 'joomengine-worker/1', 'inventory_format' => self::FORMAT, 'inventory_fingerprint' => $fingerprint, + 'inventory' => $manifest, 'form_contracts' => (object) $contracts]; + Json::encode($packed, self::MAX_WIRE_BYTES); + + return $packed; + } + + /** + * Restore every exact contract, rejecting incomplete or altered dictionaries. + * + * Validation completes before the caller can retire or persist any catalogue + * row. Limits constrain expanded work, rather than increasing HTTP budgets. + * + * @param array $result Decoded fixed-worker representation. + * @return array Original complete observed inventory. + * @since 1.0.6 + */ + public static function unpack(array $result): array + { + if (($result['inventory_format'] ?? null) !== self::FORMAT + || !is_string($result['inventory'] ?? null) + || !is_string($result['inventory_fingerprint'] ?? null) + || preg_match('/\A[0-9a-f]{64}\z/D', $result['inventory_fingerprint']) !== 1 + || (!is_array($result['form_contracts'] ?? null) && !($result['form_contracts'] ?? null) instanceof stdClass)) + { + self::invalid('The compact native inventory encoding is invalid.'); + } + + Json::encode($result, self::MAX_WIRE_BYTES); + $fingerprint = $result['inventory_fingerprint']; + $dictionary = (array) $result['form_contracts']; + if (count($dictionary) > self::MAX_ROUTES) + { + self::invalid('The native form dictionary exceeds its bounded cardinality.'); + } + $expanded = strlen($result['inventory']); + $result = Json::native(Json::decode($result['inventory'], false, self::MAX_WIRE_BYTES)); + if (!is_array($result)) + { + self::invalid('The native inventory manifest is invalid.'); + } + self::inventory($result); + $contracts = []; + $used = []; + + foreach ($dictionary as $reference => $text) + { + if (!is_string($reference) || preg_match('/\A[0-9a-f]{64}\z/D', $reference) !== 1 + || !is_string($text) || !hash_equals($reference, hash('sha256', $text))) + { + self::invalid('The native form dictionary failed its content fingerprint.'); + } + + $contract = Json::native(Json::decode($text, false, self::MAX_WIRE_BYTES)); + if (!is_array($contract) || !is_string($contract['fingerprint'] ?? null)) + { + self::invalid('The native form dictionary lacks source contract evidence.'); + } + $source = $contract; + unset($source['fingerprint']); + if (!hash_equals($contract['fingerprint'], Json::canonicalHash($source))) + { + self::invalid('The restored native form source fingerprint is invalid.'); + } + $contracts[$reference] = $contract; + } + + foreach ($result['api']['routes'] as &$route) + { + if (array_key_exists('form_contract', $route)) + { + self::invalid('The compact inventory contains an unreferenced form contract.'); + } + if (!array_key_exists('form_contract_ref', $route)) + { + continue; + } + + $reference = $route['form_contract_ref']; + if (!is_string($reference) || !isset($contracts[$reference])) + { + self::invalid('The native inventory references a missing form contract.'); + } + $expanded += strlen($dictionary[$reference]); + self::expanded($expanded); + $used[$reference] = true; + $route['form_contract'] = $contracts[$reference]; + unset($route['form_contract_ref']); + } + unset($route); + + if (count($used) !== count($dictionary)) + { + self::invalid('The native form dictionary contains unrelated contracts.'); + } + if (!hash_equals($fingerprint, Json::canonicalHash($result))) + { + self::invalid('The complete native inventory failed its observed fingerprint.'); + } + + return $result; + } + + /** @param array $result Complete inventory envelope. @return void @since 1.0.6 */ + private static function inventory(array $result): void + { + if (!is_array($result['commands'] ?? null) || !is_array($result['api'] ?? null) + || !is_array($result['api']['routes'] ?? null) || !array_is_list($result['api']['routes']) + || count($result['api']['routes']) > self::MAX_ROUTES) + { + self::invalid('A complete bounded native inventory is required.'); + } + foreach ($result['api']['routes'] as $route) + { + if (!is_array($route)) + { + self::invalid('The native inventory contains an invalid route.'); + } + } + } + + /** @param int $bytes Estimated full expanded JSON bytes. @return void @since 1.0.6 */ + private static function expanded(int $bytes): void + { + if ($bytes > self::MAX_EXPANDED_BYTES) + { + throw new OperationException('JCB_INVENTORY_LIMIT', 'The complete native inventory exceeds its bounded expanded size.'); + } + } + + /** @param string $message Safe inventory diagnostic. @return never @since 1.0.6 */ + private static function invalid(string $message): never + { + throw new OperationException('JCB_INVENTORY_INVALID', $message); + } +} diff --git a/admin/src/Service/ActionExecutor.php b/admin/src/Service/ActionExecutor.php index dcde34e..c1fc431 100644 --- a/admin/src/Service/ActionExecutor.php +++ b/admin/src/Service/ActionExecutor.php @@ -10,6 +10,7 @@ use Closure; +use stdClass; use Throwable; use VDM\Component\JoomEngineMcp\Administrator\Contract\DeferredHandlerInterface; use VDM\Component\JoomEngineMcp\Administrator\Contract\PlannedHandlerInterface; @@ -124,10 +125,12 @@ public function describe(string $name): array $document = $this->catalogue->schema((int) $resolved['binding']['input_schema_id']); $schema = SchemaDocument::decode($document); $context = CustomFields::context($resolved); + $form = $resolved['binding']['configuration']['api_form'] ?? null; + $native = $resolved['binding']['track'] === 'api' && is_array($form) ? ['nativeForm' => $form] : []; if ($context === null) { - return ['inputSchema' => $schema]; + return ['inputSchema' => $schema] + $native; } try @@ -137,13 +140,13 @@ public function describe(string $name): array catch (OperationException) { return ['inputSchema' => $schema, 'customFields' => $context + ['status' => 'unavailable', 'fields' => [], - 'reason' => 'Custom field discovery requires access to the matching fields list read action.']]; + 'reason' => 'Custom field discovery requires access to the matching fields list read action.']] + $native; } $metadata = $this->customFields($context, Json::decode($document)); $schema['properties']['data']['properties'] = (array) ($schema['properties']['data']['properties'] ?? []); - return ['inputSchema' => CustomFields::schema($schema, $metadata, true), 'customFields' => $metadata]; + return ['inputSchema' => CustomFields::schema($schema, $metadata, true), 'customFields' => $metadata] + $native; } /** @@ -217,7 +220,10 @@ public function plan(string $name, array $input, string $idempotencyKey, bool $d $input = CustomFields::normalize($input, $resolved['custom_fields']); } + [$input, $generated] = $this->generatedInput($resolved, $input, $idempotencyKey); + $input = $this->validate($resolved, $input); + $verification = $this->verificationPolicy($resolved); $before = $handler instanceof PlannedHandlerInterface ? null : $this->snapshot($resolved, $input); $delete = $this->articleDeletionPolicy($resolved, $input, $before); $preflight = $this->preflight($resolved, $input, $before); @@ -249,6 +255,11 @@ public function plan(string $name, array $input, string $idempotencyKey, bool $d $preview['customFields'] = $resolved['custom_fields']; } + if ($generated !== []) + { + $preview['generatedFields'] = $generated; + } + if ($menu !== null) { $preview['menuComponent'] = MenuItemComponents::preview($menu); @@ -275,6 +286,7 @@ public function plan(string $name, array $input, string $idempotencyKey, bool $d return $this->executions->plan($resolved, [ 'input' => $input, 'before' => $before, 'custom_fields' => $resolved['custom_fields'] ?? null, + 'api_verification' => $verification, 'menu_component' => $resolved['menu_component'] ?? null, 'delete_verification' => $delete, 'prepared' => $handler instanceof PlannedHandlerInterface ? $preflight : null, @@ -319,6 +331,11 @@ public function apply(string $token): array $this->executions->assertFresh($plan, $resolved); + if (isset($plan['payload']['api_verification'])) + { + $resolved['api_verification'] = $this->verificationPolicy($resolved, $plan['payload']['api_verification']); + } + if (isset($plan['payload']['delete_verification'])) { $resolved['delete_verification'] = $plan['payload']['delete_verification']; @@ -573,6 +590,47 @@ private function validate(array $resolved, array $input): array return $this->schemas->input($input, $document); } + /** + * Supply only an explicitly required client-generated primary GUID on create. + * + * The stable caller key determines one approved identity across repeated plans. + * Apply uses the encrypted approved input and never generates another GUID. + * Relationship identifiers and native server-generated values are untouched. + * + * @param array $resolved Authorized installed API definition. + * @param array $input Original caller arguments. + * @param string $key Validated stable operation key. + * @return array Pair of complete arguments and disclosed generated fields. + * @since 1.0.6 + */ + private function generatedInput(array $resolved, array $input, string $key): array + { + $binding = $resolved['binding']; + $config = $binding['configuration']; + $policy = $config['api_form']['generation']['guid'] ?? null; + $data = $input['data'] ?? null; + + if ($binding['track'] !== 'api' || $binding['handler'] !== 'api.request' + || ($config['operation'] ?? '') !== 'create' || ($config['method'] ?? '') !== 'POST' + || !is_array($policy) || ($policy['kind'] ?? '') !== 'guid' + || ($policy['on'] ?? '') !== 'create' || ($policy['format'] ?? '') !== 'uuid-v4' + || (!is_array($data) && !$data instanceof stdClass) + || (is_array($data) && array_is_list($data)) || array_key_exists('guid', (array) $data)) + { + return [$input, []]; + } + + $hash = hash('sha256', Json::canonical(['joomengine-mcp/generated-api-guid/v1', + $this->principal->getId(), $this->settings->get('site_alias'), $this->settings->get('api_base'), + $resolved['action']['name'], Json::requireUuid($key)])); + $guid = substr($hash, 0, 8) . '-' . substr($hash, 8, 4) . '-4' . substr($hash, 13, 3) + . '-' . dechex((hexdec($hash[16]) & 3) | 8) . substr($hash, 17, 3) . '-' . substr($hash, 20, 12); + $input['data'] = (array) $data + ['guid' => $guid]; + + return [$input, ['guid' => ['value' => $guid, 'kind' => 'guid', 'source' => 'installed-native-form', + 'reason' => 'The installed create form explicitly requires a primary GUID without a default or native server generation.']]]; + } + /** @param array $context Reviewed API context. @param array $schema Static write schema. @return array Frozen discovery metadata. @since 1.0.0 */ private function customFields(array $context, array $schema): array { @@ -759,6 +817,39 @@ private function verification(array $resolved): array ]; } + /** + * Freeze the independent identity read used by a generated API write. + * + * @param array $resolved Authorized write definition. + * @param ?array $expected Read definition approved during planning. + * @return ?array Bound read identity, or null for unchanged legacy bindings. + * @since 1.0.6 + */ + private function verificationPolicy(array $resolved, ?array $expected = null): ?array + { + $rule = $this->verification($resolved); + + if ($resolved['binding']['track'] !== 'api' || !isset($rule['identity_type']) || empty($rule['read_action'])) + { + return null; + } + + if ($expected !== null) + { + $this->catalogue->refresh(); + } + + $read = $this->resolve($rule['read_action'], 'api', 'read'); + $policy = ['action' => $read['action']['name'], 'revision' => $read['revision']]; + + if ($expected !== null && Json::canonical($policy) !== Json::canonical($expected)) + { + throw new OperationException('PLAN_STALE', 'The approved independent API verification definition changed.'); + } + + return $policy; + } + /** * Freeze independent authorized native read definitions and collection visibility. * @@ -932,13 +1023,16 @@ private function snapshot(array $resolved, array $input, ?array $expected = null $rule = $this->verification($resolved); - if (empty($rule['read_action']) || ($rule['operation'] ?? '') === 'create' || !isset($input['id'])) + $inputKey = $rule['input_key'] ?? 'id'; + + if (empty($rule['read_action']) || ($rule['operation'] ?? '') === 'create' || !isset($input[$inputKey])) { return null; } $read = $this->resolve($rule['read_action'], $resolved['binding']['track'], 'read'); - $arguments = $this->readArguments($read, $input); + $arguments = $this->readArguments($read, isset($rule['identity_type']) + ? array_replace($input, [($rule['read_input_key'] ?? $inputKey) => $input[$inputKey]]) : $input); if ($this->messageSnapshots !== null || isset($expected['snapshotContract'])) { @@ -994,6 +1088,13 @@ private function snapshot(array $resolved, array $input, ?array $expected = null $result = $this->invoke($read, $arguments); $item = $this->item($result, $read['binding']['track']); + if (isset($rule['identity_type']) + && (($identity = ApiWriteVerification::identity($input[$inputKey], $rule['identity_type'])) === null + || ApiWriteVerification::identity($item[$rule['primary_key'] ?? 'id'] ?? null, $rule['identity_type']) !== $identity)) + { + throw new OperationException('PRECONDITION_CHANGED', 'The independent API snapshot does not identify the requested resource.'); + } + if ($resolved['binding']['track'] === 'api' && preg_match('/\Amenus\.(site|administrator)-items\.update\z/D', $resolved['action']['name'], $menu) === 1 && (MenuItemComponents::identifier($item['id'] ?? null) !== $input['id'] @@ -1018,15 +1119,38 @@ private function verify(array $resolved, array $input, array $mutation): array $operation = $rule['operation'] ?? ''; $primary = $rule['primary_key'] ?? 'id'; $item = $this->item($mutation, $track); - $id = $input['id'] ?? $mutation['id'] ?? $item[$primary] ?? null; + $inputKey = $rule['input_key'] ?? 'id'; + $id = isset($rule['identity_type']) + ? ($input[$inputKey] ?? $item[$primary] ?? $input['data'][$primary] ?? null) + : ($input['id'] ?? $mutation['id'] ?? $item[$primary] ?? null); + + if (empty($rule['read_action'])) + { + return ['status' => 'notPerformed', 'reason' => 'The handler acknowledged completion but declares no independent resource read-back for this operation.']; + } + + if (isset($rule['identity_type'])) + { + $id = ApiWriteVerification::identity($id, $rule['identity_type']); - if (empty($rule['read_action']) || $id === null) + if ($id === null || (array_key_exists($primary, $item) + && ApiWriteVerification::identity($item[$primary], $rule['identity_type']) !== $id)) + { + return ['status' => 'uncertain', 'reason' => 'The mutation did not expose a valid matching resource identity for independent API verification.']; + } + } + elseif ($id === null) { return ['status' => 'notPerformed', 'reason' => 'The handler acknowledged completion but declares no independent resource read-back for this operation.']; } + if (isset($resolved['api_verification'])) + { + $this->verificationPolicy($resolved, $resolved['api_verification']); + } $read = $this->resolve($rule['read_action'], $track, 'read'); - $arguments = $this->readArguments($read, array_replace($input, ['id' => is_numeric($id) ? (int) $id : $id])); + $arguments = $this->readArguments($read, array_replace($input, isset($rule['identity_type']) + ? [($rule['read_input_key'] ?? $inputKey) => $id] : ['id' => is_numeric($id) ? (int) $id : $id])); try { @@ -1061,6 +1185,11 @@ private function verify(array $resolved, array $input, array $mutation): array $record = $this->item($observed, $track); + if (isset($rule['identity_type']) && ApiWriteVerification::identity($record[$primary] ?? null, $rule['identity_type']) !== $id) + { + return ['status' => 'uncertain', 'reason' => 'The deletion read-back did not identify the requested resource.', 'id' => $id]; + } + if (!isset($resolved['delete_verification']) && ($record[$rule['state_field'] ?? 'state'] ?? null) == -2) { return ['status' => 'verified', 'postcondition' => 'resource-trashed', 'id' => $id]; @@ -1076,6 +1205,11 @@ private function verify(array $resolved, array $input, array $mutation): array return ['status' => 'uncertain', 'reason' => 'The written resource could not be read back.', 'id' => $id]; } + if (isset($rule['identity_type']) && ApiWriteVerification::identity($record[$primary] ?? null, $rule['identity_type']) !== $id) + { + return ['status' => 'uncertain', 'reason' => 'The independent API read-back did not identify the written resource.', 'id' => $id]; + } + $desired = $operation === 'state' ? [($rule['state_field'] ?? 'state') => $input['state']] : ($input['data'] ?? []); foreach ($resolved['custom_fields']['fields'] ?? [] as $field) @@ -1092,9 +1226,17 @@ private function verify(array $resolved, array $input, array $mutation): array $different = []; $unobservable = []; $automaticOrdering = ApiWriteVerification::automaticOrdering($resolved, $input) !== null; + $requestOnly = ApiWriteVerification::requestOnly($resolved, $read); + $controls = []; foreach ($desired as $field => $value) { + if (in_array($field, $requestOnly, true)) + { + $controls[] = $field; + continue; + } + if ($automaticOrdering && $field === 'ordering') { // Zero requests native assignment; it is not a literal persisted value. @@ -1125,6 +1267,12 @@ private function verify(array $resolved, array $input, array $mutation): array 'reason' => $different === [] ? 'Read-back confirms the listed observable fields; write-only fields cannot be compared.' : 'Joomla may have filtered or changed requested fields. Reconcile the persisted record before another write.', ]; + if ($controls !== []) + { + $verification['requestOnlyFields'] = $controls; + $verification['reason'] .= ' Declared request-only controls are listed separately; their persistence is not claimed.'; + } + if ($automaticOrdering) { $verification['nativeOrdering'] = ApiWriteVerification::verifyOrdering($resolved, $read, $id, $item, $record); diff --git a/admin/src/Service/ApiWriteVerification.php b/admin/src/Service/ApiWriteVerification.php index 358297a..cb6e180 100644 --- a/admin/src/Service/ApiWriteVerification.php +++ b/admin/src/Service/ApiWriteVerification.php @@ -10,6 +10,7 @@ use stdClass; +use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; /** @@ -56,6 +57,20 @@ final class ApiWriteVerification */ public static function compare(array $resolved, array $read, string $field, mixed $desired, mixed $observed): ?bool { + $form = self::form($resolved, $read); + + if (isset($form['fields'][$field]) && is_array($form['fields'][$field])) + { + return self::formValue($desired, $observed, $form['fields'][$field]); + } + + if ($form !== []) + { + // Undeclared fields retain JSON equality rather than inheriting a + // numeric or shape coercion from an unrelated field contract. + return Json::canonical($desired) === Json::canonical($observed); + } + $base = self::resource($resolved); if ($base === null || !self::nativeRead($read, $base)) @@ -91,6 +106,179 @@ public static function compare(array $resolved, array $read, string $field, mixe return null; } + /** + * Return declared form controls without claiming they were persisted. + * + * @param array $resolved Authorized write definition. + * @param array $read Authorized independent read definition. + * @return string[] Source-backed request-only controls. + * @since 1.0.6 + */ + public static function requestOnly(array $resolved, array $read): array + { + $controls = self::form($resolved, $read)['request_only'] ?? []; + + return is_array($controls) ? array_values(array_filter($controls, 'is_string')) : []; + } + + /** + * Compare resource identity using its declared route contract. + * + * @param mixed $value Native resource identity. + * @param string $type Registered identity representation. + * @return int|string|null Exact normalized identity, or null when invalid. + * @since 1.0.6 + */ + public static function identity(mixed $value, string $type): int|string|null + { + if ($type === 'integer') + { + $integer = self::integral($value); + + return $integer !== null && $integer > 0 ? $integer : null; + } + + if ($type === 'guid') + { + return is_string($value) && preg_match('/\A[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\z/Di', $value) === 1 + ? strtolower($value) : null; + } + + if ($type === 'string') + { + return is_string($value) && $value !== '' && strlen($value) <= 255 && preg_match('//u', $value) === 1 + && !in_array($value, ['.', '..'], true) && preg_match('/[\\\\\/%?#\x00-\x1f\x7f]/', $value) !== 1 ? $value : null; + } + + return null; + } + + /** + * Bind installed form representation rules to their independent API item read. + * + * @param array $resolved Authorized write definition. + * @param array $read Authorized read definition. + * @return array Installed, administrator-owned form verification metadata. + * @since 1.0.6 + */ + private static function form(array $resolved, array $read): array + { + $write = $resolved['binding'] ?? []; + $item = $read['binding'] ?? []; + $config = $write['configuration'] ?? []; + $readConfig = $item['configuration'] ?? []; + $route = $write['definition']['nativeRoute'] ?? []; + $readRoute = $item['definition']['nativeRoute'] ?? []; + + if (($write['track'] ?? '') !== 'api' || ($item['track'] ?? '') !== 'api' + || ($write['handler'] ?? '') !== 'api.request' || ($item['handler'] ?? '') !== 'api.request' + || ($readConfig['method'] ?? '') !== 'GET' || !empty($readConfig['select_fields']) + || ($config['authentication'] ?? '') !== 'joomla-api-token' + || ($readConfig['authentication'] ?? '') !== 'joomla-api-token' + || ($config['read_action'] ?? '') !== ($read['action']['name'] ?? null) + || ($route['route'] ?? null) !== ($config['route'] ?? '') + || ($readRoute['route'] ?? null) !== ($readConfig['route'] ?? '') + || ($route['defaults']['component'] ?? null) !== ($readRoute['defaults']['component'] ?? '') + || !is_string($route['controller'] ?? null) || !is_string($readRoute['controller'] ?? null) + || substr($route['controller'], 0, (int) strrpos($route['controller'], '.')) + !== substr($readRoute['controller'], 0, (int) strrpos($readRoute['controller'], '.')) + || !is_array($config['api_form']['verification'] ?? null)) + { + return []; + } + + // This metadata is extracted during installed catalogue synchronization; + // neither a client input nor an API response may supply comparison rules. + return $config['api_form']['verification']; + } + + /** + * Apply only declared native representations, retaining shape and list order. + * + * @param mixed $desired Approved field value. + * @param mixed $observed Independently saved field value. + * @param array $contract Source-backed field and child representations. + * @param int $depth Bounded child traversal. + * @return bool Whether the exact declared field postcondition holds. + * @since 1.0.6 + */ + private static function formValue(mixed $desired, mixed $observed, array $contract, int $depth = 0): bool + { + if ($depth > 32) + { + return false; + } + + $representation = $contract['representation'] ?? ''; + + if ($representation === 'integer') + { + $expected = self::integral($desired); + $actual = self::integral($observed); + + return $expected !== null && $actual !== null && $expected === $actual; + } + + if ($representation === 'boolean') + { + $values = [false, true, 0, 1, '0', '1']; + + return in_array($desired, $values, true) && in_array($observed, $values, true) + && (bool) $desired === (bool) $observed; + } + + if ($representation === 'json') + { + try + { + $desired = is_string($desired) ? Json::native(Json::decode($desired, false)) : $desired; + $observed = is_string($observed) ? Json::native(Json::decode($observed, false)) : $observed; + } + catch (OperationException) + { + return false; + } + } + + if (!is_array($desired) && !$desired instanceof stdClass) + { + return Json::canonical($desired) === Json::canonical($observed); + } + + if ((!is_array($observed) && !$observed instanceof stdClass) + || (is_array($desired) && array_is_list($desired)) !== (is_array($observed) && array_is_list($observed))) + { + return false; + } + + $expected = (array) $desired; + $actual = (array) $observed; + + if (count($expected) !== count($actual) || array_diff_key($expected, $actual) !== []) + { + return false; + } + + if (($contract['ordered'] ?? false) === true && array_keys($expected) !== array_keys($actual)) + { + return false; + } + + $list = is_array($desired) && array_is_list($desired); + + foreach ($expected as $key => $child) + { + $childContract = $list ? ($contract['items'] ?? []) : ($contract['properties'][$key] ?? $contract['additionalProperties'] ?? []); + + if (!is_array($childContract) || !self::formValue($child, $actual[$key], $childContract, $depth + 1)) + { + return false; + } + } + + return true; + } + /** * Disclose native assignment without predicting the next position-wide order. * diff --git a/admin/src/Service/Catalogue.php b/admin/src/Service/Catalogue.php index fa4bfa6..f1c7135 100644 --- a/admin/src/Service/Catalogue.php +++ b/admin/src/Service/Catalogue.php @@ -112,6 +112,16 @@ public function __construct( */ public function refresh(): void { + // Release the previous parsed graph before constructing its replacement. + // Binding indexes also retain native form graphs. A failed load must leave + // no old authorized snapshot available; publish only the complete new rows. + $this->records = []; + $this->names = []; + $this->actionBindings = []; + $this->visibility = []; + $this->resolvedBindings = []; + $this->extensions = []; + $this->validatedSchemas = []; $records = []; $names = []; $bindings = []; @@ -179,10 +189,6 @@ public function refresh(): void $this->records = $records; $this->names = $names; $this->actionBindings = $bindings; - $this->visibility = []; - $this->resolvedBindings = []; - $this->extensions = []; - $this->validatedSchemas = []; } /** diff --git a/admin/src/Service/Json.php b/admin/src/Service/Json.php index b4ea22e..4d4ea63 100644 --- a/admin/src/Service/Json.php +++ b/admin/src/Service/Json.php @@ -9,6 +9,7 @@ namespace VDM\Component\JoomEngineMcp\Administrator\Service; +use HashContext; use JsonException; use stdClass; use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; @@ -86,6 +87,77 @@ public static function canonical(mixed $value): string return self::encode(self::normalise($value)); } + /** + * Hash canonical inventory JSON without materializing an aggregate response. + * + * Individual values and nesting remain bounded; callers must separately bound + * inventory cardinality. Ordinary request and result encoding limits are unchanged. + * + * @param mixed $value JSON-compatible observed inventory. + * @return string SHA-256 of exactly the canonical JSON bytes. + * @since 1.0.6 + */ + public static function canonicalHash(mixed $value): string + { + $context = hash_init('sha256'); + $bytes = 0; + self::hashValue($context, $value, 0, $bytes); + + return hash_final($context); + } + + /** @param HashContext $context Incremental digest. @param mixed $value JSON value. @param int $depth Bounded nesting. @param int $bytes Canonical aggregate bytes. @return void @since 1.0.6 */ + private static function hashValue(HashContext $context, mixed $value, int $depth, int &$bytes): void + { + if ($depth > 64) + { + throw new OperationException('RESULT_INVALID', 'The inventory exceeds the supported JSON nesting depth.'); + } + + $isObject = $value instanceof stdClass; + if ($isObject) + { + $value = get_object_vars($value); + } + + if (!is_array($value)) + { + self::hashChunk($context, self::encode($value), $bytes); + return; + } + + $isList = !$isObject && array_is_list($value); + if (!$isList) + { + ksort($value, SORT_STRING); + } + + self::hashChunk($context, $isList ? '[' : '{', $bytes); + $separator = ''; + foreach ($value as $key => $child) + { + self::hashChunk($context, $separator, $bytes); + if (!$isList) + { + self::hashChunk($context, self::encode((string) $key) . ':', $bytes); + } + self::hashValue($context, $child, $depth + 1, $bytes); + $separator = ','; + } + self::hashChunk($context, $isList ? ']' : '}', $bytes); + } + + /** @param HashContext $context Incremental digest. @param string $chunk Exact canonical bytes. @param int $bytes Aggregate byte count. @return void @since 1.0.6 */ + private static function hashChunk(HashContext $context, string $chunk, int &$bytes): void + { + $bytes += strlen($chunk); + if ($bytes > 67108864) + { + throw new OperationException('RESULT_TOO_LARGE', 'The canonical inventory exceeds its bounded aggregate size.'); + } + hash_update($context, $chunk); + } + /** * Expose ordinary JSON maps to PHP while retaining ambiguous object shapes. * diff --git a/admin/src/Service/RuntimeFactory.php b/admin/src/Service/RuntimeFactory.php index 07932a6..38afbc2 100644 --- a/admin/src/Service/RuntimeFactory.php +++ b/admin/src/Service/RuntimeFactory.php @@ -24,6 +24,7 @@ use VDM\Component\JoomEngineMcp\Administrator\Jcb\CommandHandler; use VDM\Component\JoomEngineMcp\Administrator\Jcb\CommandInput; use VDM\Component\JoomEngineMcp\Administrator\Jcb\DefinitionSnapshot; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\InventoryTransport; use VDM\Component\JoomEngineMcp\Administrator\Job\Artifacts; use VDM\Component\JoomEngineMcp\Administrator\Job\Jobs; use VDM\Component\JoomEngineMcp\Administrator\Job\ProcessLauncher; @@ -310,15 +311,16 @@ public function work(ConsoleApplication $application, string $id, string $ticket */ public function synchronizeJcb(CMSApplicationInterface $application, PrincipalInterface $principal): array { - if ((!$principal->isLocal() && (!$principal->authorise('core.admin', 'com_joomengine_mcp') - || !$principal->authorise('core.admin', 'com_componentbuilder'))) + if ((!$principal->isLocal() && !$principal->authorise('core.admin', 'com_joomengine_mcp')) || ($principal->isLocal() && (!$application instanceof ConsoleApplication || PHP_SAPI !== 'cli'))) { throw new OperationException('JCB_CATALOGUE_DENIED', 'Catalogue synchronization requires native component administration permission.'); } $result = $this->workerResult($this->jcbProcess()->run(['protocol' => 'joomengine-worker/1', 'operation' => 'jcb.inventory', - 'authority' => ['id' => $principal->getId(), 'track' => $principal->getTrack()]], 60, 8388608, static fn (): bool => false)); + 'inventory_format' => InventoryTransport::FORMAT, + 'authority' => ['id' => $principal->getId(), 'track' => $principal->getTrack()]], 60, InventoryTransport::MAX_WIRE_BYTES, static fn (): bool => false)); + $result = InventoryTransport::unpack($result); $store = new JoomlaStore($this->database); return (new CatalogueSynchronizer($store, [new Assets($this->database, $store), 'synchronize'])) diff --git a/admin/tmpl/operations/default.php b/admin/tmpl/operations/default.php index 9dc21dc..16c5da0 100644 --- a/admin/tmpl/operations/default.php +++ b/admin/tmpl/operations/default.php @@ -20,7 +20,7 @@ -authorise('core.admin', 'com_joomengine_mcp') && $user->authorise('core.admin', 'com_componentbuilder')) : ?> +authorise('core.admin', 'com_joomengine_mcp')) : ?>
diff --git a/changelog.xml b/changelog.xml index 4311f98..fac47bc 100644 --- a/changelog.xml +++ b/changelog.xml @@ -4,6 +4,14 @@ com_joomengine_mcp component [[[NEXT_VERSION]]] + + Synchronize full-size installed generated API catalogues with bounded shared form-contract transport and incremental inventory fingerprints. Release the previous catalogue snapshot before refreshing, avoid an unused catalogue preload during synchronization, and reject invalid inventories or failed refreshes before reusing definitions. Preserve every native route and validation policy, and exercise the complete supplied API-enabled JCB package through repeated installed synchronization and HTTP discovery. + Preserve effective administrator input restrictions during catalogue upgrades when only the referenced schema was customized. Keep its tool, action, prompt or binding attached to that policy, including hash-detected edits and disabled schemas; verify allowed and denied requests before and after upgrade. + Identify Joomla core components through the native core-extension catalogue during API synchronization. Preserve their existing MCP bindings while supporting enabled third-party components independently of uninstall and disable protection flags; exercise registered-route synchronization and nested-filter reads in the installed Joomla and JCB checks. + Describe generated API inputs from their installed native forms, including nested subforms, GUID relationships and validation metadata. Generate a required record GUID only when the native contract calls for one, freeze it in the approved plan, and verify writes through an independently bound item read. Preserve omitted PATCH fields and report unverifiable native responses truthfully. + Support observed GUID and alternate unique-key routes across installed generated component APIs, with scoped provider permissions, bounded multiselect filters and exact independent item-read bindings. Preserve existing Joomla route encoders and unselected provider definitions during synchronization. + Accept bounded nested JSON inputs in the generic API and companion read tools while retaining selected-action validation, existing Joomla MCP behavior and administrator-owned schema policies. + Add package-first Joomla setup, administrator-area, token/ACL, tool, confirmed-write, JCB and recovery guidance, with linked AI and direct-client connection instructions. @@ -128,4 +136,3 @@ Development baseline; published immutable tags establish release availability. - diff --git a/data/runtime-tools.json b/data/runtime-tools.json index 91c949a..8768d79 100644 --- a/data/runtime-tools.json +++ b/data/runtime-tools.json @@ -45,6 +45,81 @@ ], "$schema": "http://json-schema.org/draft-07/schema#" } + }, + { + "name": "joomla_action_read", + "reason": "Generic read actions accept bounded JSON-valued arguments, including nested filters and objects declared by installed extension action schemas. The action-specific schema and shared input depth, collection, field-name and byte bounds remain authoritative; routes, handlers and authority remain server controlled.", + "inputSchema": { + "type": "object", + "properties": { + "site": { + "description": "Configured site alias; omit for the default site.", + "type": "string", + "minLength": 1 + }, + "action": { + "type": "string", + "minLength": 3, + "maxLength": 160 + }, + "input": { + "description": "Bounded JSON argument object validated again against the selected action schema. Nested objects and arrays retain their JSON types.", + "default": {}, + "type": "object", + "maxProperties": 512, + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + }, + "transport": { + "default": "auto", + "type": "string", + "enum": [ + "auto", + "api", + "cli" + ] + } + }, + "required": [ + "action" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } + }, + { + "name": "joomla_companion_action_read", + "reason": "The local companion read envelope accepts the same bounded JSON-valued action arguments as generic reads. Native action schemas, trusted CLI authority and shared input limits still validate every invocation.", + "inputSchema": { + "type": "object", + "properties": { + "site": { + "description": "Configured site alias; omit for the default site.", + "type": "string", + "minLength": 1 + }, + "action": { + "type": "string", + "minLength": 3, + "maxLength": 160 + }, + "input": { + "description": "Bounded JSON argument object validated again against the selected native action schema. Nested objects and arrays retain their JSON types.", + "default": {}, + "type": "object", + "maxProperties": 512, + "propertyNames": { + "type": "string" + }, + "additionalProperties": {} + } + }, + "required": [ + "action" + ], + "$schema": "http://json-schema.org/draft-07/schema#" + } } ], "tools": [ diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index ec4ce4c..7899d68 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -32,7 +32,11 @@ Published provider, schema, action, binding, tool, resource, prompt and CLI-targ Permission request schemas accept bounded toolset names so installed providers can add write scopes such as `jcb.execute`. The permission service checks every requested scope against currently published, authorized write actions at request, approval and use. `data/runtime-tools.json` declares this extension to the original core-only input enum; generated database schemas retain its provenance. Upgrades preserve the original schema for administrator-owned tools with deliberately narrower policies. -The JCB provider uses `com_componentbuilder` dependency/version metadata and stable source identities. Reuse schemas and binding mechanics across actual routes and registered command families. Do not generate routes from table names or a get/init/pull/push/reset Cartesian product from the package entity map. Source inventory, runtime availability and tested support are separate. Missing/disabled JCB hides its runnable definitions without harming Joomla core. +The JCB provider uses `com_componentbuilder` dependency/version metadata and stable source identities. Explicit synchronization also inventories registered APIs for enabled installed third-party components under per-component native administration authority. The MCP component and exact Joomla core component identities from `ExtensionHelper::getCoreExtensions()` are excluded from this generated scope; existing core definitions remain authoritative. Extension `protected`/`locked` flags are not API ownership or permission boundaries. Each selected component has its own provider, route ownership and plugin dependencies. Reuse schemas and binding mechanics across actual routes and registered command families. Do not generate routes from table names or a get/init/pull/push/reset Cartesian product from the package entity map. Source inventory, runtime availability and tested support are separate. Missing/disabled JCB hides its runnable definitions without harming Joomla core or unrelated installed component APIs. + +Installed form contracts come from confined, literal API-controller/model/form mappings during synchronization. Source hashes bind their field/filter/default/conditional/subform descriptors to generated schemas and verification metadata. Native defaults are descriptive and do not populate omitted PATCH fields; native validators and ACL remain authoritative. Only a declared required primary GUID without a default or detected server-generation rule may be generated before approval, disclosed in the plan and retained in its immutable input. Related record identifiers are never generated. + +Generated CRUD bindings preserve reviewed numeric, GUID and unique-key route identities and fixed defaults. Plans freeze their independent item-read revision; snapshots and mutation read-back must identify the same resource. Native form representation rules permit only declared comparisons, while object/list shape, row keys, list order and undeclared values remain strict. Request-only controls are disclosed separately. Unsupported specialized tasks retain diagnostics; source/fixture coverage is not live installed API parity. See IMPLEMENTATION.md for current evidence and upstream limits. Viewing levels resolve groups through Joomla; each editable row has an asset_id. Provider state, record publication, authorized view levels, asset/action ACL, handler availability, target ACL, version compatibility and track are independent checks. Apply the same predicate to discovery/search/describe/direct calls and recheck at execution. diff --git a/docs/CLIENT-CONNECTIONS.md b/docs/CLIENT-CONNECTIONS.md index d7dbf2b..e0829d5 100644 --- a/docs/CLIENT-CONNECTIONS.md +++ b/docs/CLIENT-CONNECTIONS.md @@ -289,4 +289,3 @@ The SDK also exposes `listResources()`, `listResourceTemplates()`, `readResource | Timeout during a write/job | Read the resulting operation/job state before resubmitting | See [site setup](GETTING-STARTED.md), [write confirmations](GETTING-STARTED.md#plan-and-confirm-writes), [JCB jobs](GETTING-STARTED.md#enable-jcb-operations-and-background-jobs), [administrator operations](GETTING-STARTED.md#use-the-administrator-areas), [security](../SECURITY.md) and the [client README](https://github.com/joomengine/mcp_client). - diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md index ed4e3ff..af81967 100644 --- a/docs/IMPLEMENTATION.md +++ b/docs/IMPLEMENTATION.md @@ -1,5 +1,39 @@ # Implementation status — 2 October 2026 +## Full installed generated-API inventory — current follow-up + +The supplied API-enabled JCB package exposes a larger native form inventory than the original golden-image fixture. On MCP `047c08ee`, repeating a complete form contract for each registered method can exceed the inventory worker's 8 MiB output allowance. The enriched inventory and catalogue revision also previously serialized the entire expanded inventory through an 8 MiB canonical-JSON limit. This is an MCP inventory limitation; the generated API files are not a repair target. + +This follow-up keeps the worker-output and public request/result limits unchanged. Inventory-only transport shares identical native form contracts by a verified content reference and reconstructs every registered route before catalogue synchronization. Aggregate fingerprints use bounded incremental canonical hashing. Native controller/form provenance, method-specific field policy, component ownership, diagnostics and administrator customization remain part of the inventory. Expanded inventory size and reference counts must also be bounded, with invalid references refused before database changes. + +The installed regression uses the exact supplied API-enabled JCB ZIP, verified by SHA-256, and a separate disposable routing plugin rendered by its native JCB compiler. The historical linked webservices plugin was not supplied. All 320 native registrations, 318 form-backed routes, 51 forms and 63 GUID routes must reach the installed `joomla:mcp:jcb-sync` command. Its actual expanded inventory must exceed the old 8 MiB boundary without padding, while compact IPC stays below it. The test compares every persisted schema/action/binding contract, repeats the real command, and checks existing core definitions, authenticated core forwarding and an administrator customization. This establishes full-size synchronization, not complete native CRUD acceptance. Fixture provenance is in [the fixture README](../tests/fixtures/jcb/README.md). + +The first installed run on `394f277` confirmed the actual 111-command inventory was 8,468,079 bytes expanded and 3,336,313 bytes compact. The unnegotiated worker failed with `WORKER_OUTPUT_LIMIT`; the real command then synchronized all 320 routes, and every persisted contract passed its byte-hash and schema-parser checks on Joomla 6.1.4. Preparation took 4.983 seconds with a 52,428,800-byte fixture peak. The full authenticated action search subsequently exposed a second issue: catalogue refresh retained the previous decoded graph while constructing the next graph, exceeding Apache's 128 MiB limit. Refresh now invalidates the previous snapshot and all dependent indexes/policy caches before rebuilding, publishes only a complete graph, and leaves failed refreshes unable to reuse old authority or definitions. The full installed HTTP search remains an acceptance requirement. + +The next installed run on `0802504` passed authenticated discovery of all 320 actions, core API forwarding, native customization and 4,339 full-graph verification checks. It exposed a separate overlap on the second console sync: the command loaded the existing parsed catalogue before entering its independently privileged synchronization branch, retaining it alongside the new inventory, seed and database rows. The sync branch now returns before that unused preload; other console operations keep their normal refresh. The existing full-size second command and customization-preservation assertions remain required under the same 128 MiB hosting limit. + +All 32 existing local PHP contract suites passed, along with 43 new isolated-process, type-preservation, fingerprint, corruption, Unicode and expanded-size checks. A separate 23-check, 320-route refresh regression runs under a fixed 128 MiB limit, with a 111,149,056-byte peak; the same fixture fails on the second refresh with the previous code. It also checks current schemas/names, unflagged configuration edits, authority/extension/handler revocation, invalid-row failures and recovery without stale fallback. Composer validation, locked dependency verification, PHP syntax and source-pinned seed reproducibility also passed. Fresh installed golden-image evidence and the PHP/database matrix results are retained in the workflows linked from [PR #38](https://github.com/joomengine/mcp_component/pull/38); previous green runs do not establish this full-size case. + +## Generic installed component API contracts — current change + +Explicit catalogue synchronization now inspects registered API routes for enabled installed third-party components for which the operator has native administration permission. Joomla core component identities come from the native `ExtensionHelper::getCoreExtensions()` inventory and are excluded along with the MCP component; the existing Joomla core catalogue and execution contracts remain in place. Extension `protected` and `locked` flags do not define this API scope: they govern extension removal or disabling, not API authority. The administrator refresh action and the compatibility command `joomla:mcp:jcb-sync` synchronize these component APIs alongside the separately inventoried JCB commands. Each component retains its own provider, permission ceiling, registration-plugin dependencies and unsupported-contract diagnostics. + +The generic route adapter supports reviewed native CRUD method/task pairs, numeric item IDs, declared GUID aliases and registered unique-key paths. It preserves literal route defaults and bounded nested filter input. It does not infer routes from table names, generate missing endpoints or expose specialized controller tasks without an adapter. Ordinary discovery still reads persisted catalogue rows and performs no synchronization. + +`FormContracts` follows literal installed API-controller/model/form bindings and confines source/XML reads to the installed component. It describes native field filters, defaults, choices, conditional requirements, relationship identifiers and nested subforms, with source hashes. Defaults are descriptive: they are not injected into PATCH data, and omitted update fields remain omitted. Dynamic model validation, custom field rules and runtime ACL remain native API responsibilities. A missing literal form mapping is disclosed; unsafe bound forms are refused rather than guessed. + +Only a primary `guid` explicitly required by the bound create form, without a default, detected native generation or conditional requirement, receives a client-generated UUID. The generated identity is stable for the principal/site/action/idempotency key, disclosed in the plan and frozen with the approved input. Supplied GUIDs are preserved. Relationship GUIDs are resolved from authorized API records and are never invented. + +Generated writes freeze their independent read-action revision and require matching typed resource identity on snapshots and read-back. Representation comparisons use only the bound native form's declared integer, boolean and JSON contracts; object/list distinctions, row keys and list order remain significant. Declared request-only controls are reported separately without claiming persistence. Undeclared fields retain strict comparison, and native errors or genuinely unobservable values remain uncertain. + +Local validation passed all 32 PHP contract suites: the 27 existing suites plus generated API catalogue (58 checks), transport (86), inventory (18), form-contract (29) and API-verification (126) suites. Source-ZIP installation and relocated-runtime checks passed 1,861 assertions; PHP syntax, source-pinned seed reproducibility, Composer validation and the locked dependency dry run also passed. The new contract suites primarily use router/form/transport fixtures. An additional source inspection of the supplied compiled JCB 6.1.6 package passed 30 form-contract checks and found bindings for 102 controller files covering 51 administrator forms. Neither result is live generated JCB GUID CRUD evidence. + +Installed testing exposed an incorrect earlier assumption that `protected = 0` identifies third-party components: Joomla's core `com_contact` can have `protected = 0` and `locked = 1`. Selection now uses Joomla's exact native core identities instead. At source commit `f10f390`, all seven [PR #38](https://github.com/joomengine/mcp_component/pull/38) checks passed: two PHP contract jobs, four installed Joomla jobs and one installed JCB golden-image job, across workflow runs [37030966447](https://github.com/joomengine/mcp_component/actions/runs/37030966447), [37030966411](https://github.com/joomengine/mcp_component/actions/runs/37030966411) and [37030966496](https://github.com/joomengine/mcp_component/actions/runs/37030966496). Acceptance evidence is pinned to that source commit; later revisions require their own completed checks. + +`tests/integration/generated-api.php` passed in all five installed jobs. It exercises actual contact webservices registration, an explicitly scoped persisted generic catalogue, an authenticated nested-filter HTTP read, unchanged existing core providers/bindings and native-model cleanup. Its explicit contact scope is an adapter test, not production core selection or live generated-component GUID CRUD evidence. + +The separate 2 October isolated test report remains historical installed evidence: 47 of 51 writable JCB families completed CRUD, while `components_config`, `components_dashboard`, `language_translations` and `libraries_config` returned HTTP 500 through both MCP and the direct native API. Exact exception causes were not captured. Native read-only item redirects and the optional-description compiler crash also remain upstream findings. The current generic changes do not claim to fix these failures, specialized custom-admin tasks or complete API-only JCB parity. Fresh installed acceptance must exercise the current source, GUID routes, nested filters, conditional forms, relationships and strict read-back; phase 2 retains the upstream endpoint/ACL/compiler repairs and their live acceptance. + ## Native field representation verification follow-up The fresh installed API audit found three additional representation mismatches. The empty Registry contract now covers `params` for content, banners, contacts and newsfeeds categories, and `images`, `urls` and `metadata` for articles. It requires the reviewed native write and independent read bindings, and equates only an explicitly approved empty object with an actually empty native Registry representation. Article `attribs` remains explicitly unobservable when absent from native API read-back. Nonempty, nested, unrelated and malformed values retain strict comparison. @@ -118,6 +152,10 @@ JCB synchronization inspects actual installed route and console registries. `Cat Synchronization records the native registration-plugin dependencies. Disabling or uninstalling a registering plugin immediately hides its persisted operations. Permission schemas support authorized installed provider scopes such as `jcb.execute`, while exact scope checks, publication/ACL changes, revocation and one-use consumption remain enforced. Customized administrator schemas are preserved on upgrade. +Schema ownership also protects the effective validation relationship. When an upgrade replaces a tool, action, prompt or binding's input or output schema, a customized installed schema keeps its existing dependant attached to that policy. Explicit ownership, hash-detected edits, custom schemas and missing schema references prevent automatic replacement. A disabled schema continues to make the definition unavailable. Untouched shipped definitions still receive the new schema. + +`tests/schema-upgrade.php` passes 130 before/after/repeated-upgrade checks through the real `ToolDispatcher`, catalogue, validator and action executor over test-owned storage and recording read transports. Both generic read tools retain a schema-only action/ID restriction; rejected inputs produce no transport call. Binding input/output policies, disabled/missing schemas, an owned schema omitted from the incoming graph and pristine upgrades are covered. The same regression fails against the previous installer because a formerly denied ID becomes accepted after upgrade. The existing `tests/catalogue.php` invokes this suite in both PHP CI jobs and retains its original 7,786 checks. All 32 local PHP suites, changed-file syntax and checkout/relocated-runtime checks pass with this fix. The complete follow-up CI matrix must pass on the new PR head before readiness. + JCB commands require approved plans. `CommandInput` freezes supplied/native environment inputs; command/source fingerprints and a JCB definition/configuration snapshot protect against stale execution. Registered native implementations execute in isolated PHP children. Package operations remain writes, including `get`; a missing native handler is reported rather than counted as successful coverage. Approval previews show the selected definitions, frozen option layers, repository identity, effects and revision fingerprints from that same immutable preparation. Credentials and server paths remain private. The graph revision guard covers the entire installed JCB definition/configuration graph; remote dependency traversal remains native execution work, not a falsely enumerated preview. API validation errors retain bounded native field/checkout diagnostics, and both local and remote stdio enforce byte limits before ignoring blank frames. diff --git a/docs/integrations/JCB.md b/docs/integrations/JCB.md index 42f3ccc..a20d1db 100644 --- a/docs/integrations/JCB.md +++ b/docs/integrations/JCB.md @@ -1,5 +1,13 @@ # Required integration: Joomla Component Builder +## Generated component API compatibility objective + +The installed API contracts generated by JCB are authoritative. MCP must support registered CRUD routes for JCB itself and for other installed components compiled with JCB, including numeric IDs, GUIDs, unique keys, nested filters, native form/subform inputs and independently verified saved values. Preserve all existing Joomla core MCP tools, protocol, permissions, plans and verification behavior. API defects are corrected in the JCB compiler, templates, powers or component definitions and then regenerated; generated API files are not the maintenance source. + +Custom administrator views can implement arbitrary workflows. This objective does not add automatic API generation for compiler, package or extrusion custom administrator views. Existing explicitly reviewed native command jobs retain their contracts; they are separate from native entity API CRUD coverage. + +The 2 October 2026 supplied integration report established 47 complete writable resource lifecycles out of 51 and four native API create failures. It also confirmed scalar-only generic read envelopes rejecting nested filters and 63 unsupported GUID aliases. Those MCP compatibility gaps are in scope here. Native API HTTP 500s, native read permission redirects and compiler null handling remain upstream issues and cannot be converted into successful MCP coverage by relaxing verification. + ## 1. Objective and completion boundary JoomEngine MCP must support **the entire actual API and CLI surface of Joomla Component Builder (JCB)** in addition to the preserved Joomla core capabilities. JCB support is a first-class delivery requirement, not a sample third-party integration or an optional improvement after the core release. @@ -12,6 +20,20 @@ JCB may be absent on an individual Joomla site. In that case the server must ret ## 2. Source evidence and unresolved boundaries +### Current generic API contract and acceptance boundary + +The installed inventory is no longer restricted to JCB-owned entity routes. Explicit synchronization selects enabled third-party components from Joomla's extension registry, excluding the MCP component and the exact core component identities returned by Joomla's `ExtensionHelper::getCoreExtensions()`, and intersects native `core.admin` authority for every selected component. Extension `protected` and `locked` flags govern removal/disabling and are not API ownership or permission tests. Generated component APIs receive separate providers and keep their native route/plugin provenance. Existing Joomla core definitions retain their established catalogue and behavior. The compatibility command remains `joomla:mcp:jcb-sync`; JCB commands still have their separate native registration and permission requirements. + +Literal API-controller/model/form mappings provide source-fingerprinted descriptors for defaults, choices, native filters, conditional validation, relationship keys and nested subforms. This metadata informs clients and verification; the native API remains responsible for runtime form changes, custom validators and ACL. XML defaults do not fill omitted PATCH fields. A create plan generates only an explicitly required primary GUID without a native default or detected server-generation rule, discloses it and freezes it across apply/replay. Relationship identifiers must identify actual authorized records. + +The generic adapter accepts declared numeric, GUID and unique-key item routes, preserves fixed defaults, and supports bounded nested filter input. Specialized tasks and unsupported route rules remain explicit diagnostics, including custom-admin tasks outside the reviewed CRUD pairs. A declared item route must support independent API read-back with matching identity; only source-bound representations can normalize comparison, and request-only controls are never reported as persisted values. + +Current validation comprises passing generic contract fixtures and source inspection of the supplied compiled JCB 6.1.6 package: 102 API controller files bind to 51 administrator forms. No live JCB CRUD run of these changes has been performed in this environment. The separate 2 October installed report completed 47 of 51 writable families and reproduced direct native POST HTTP 500 failures for `components_config`, `components_dashboard`, `language_translations` and `libraries_config`. These upstream failures, read-only item ACL redirects and the optional-description compiler failure remain separate work. See [the current evidence and limits](../IMPLEMENTATION.md#generic-installed-component-api-contracts--current-change). + +At source commit `f10f390`, all seven [PR #38](https://github.com/joomengine/mcp_component/pull/38) checks passed, including four installed Joomla jobs and the installed JCB golden-image job. `tests/integration/generated-api.php` passed in all five installed jobs: it uses actual contact webservices registration under an explicit disposable adapter scope, persists its catalogue, reads the native API through authenticated MCP with a nested filter, preserves the existing core provider/binding and cleans up through native models. This is installed generic adapter evidence, not live generated-component GUID CRUD or complete JCB execution parity. Subsequent revisions require their own completed checks; the [implementation record](../IMPLEMENTATION.md#generic-installed-component-api-contracts--current-change) links the accepted source's workflow runs and local checks. + +Complete API-only acceptance additionally requires live CRUD/read-back/cleanup for every writable family, all declared GUID routes, real conditional/relationship cases, and reviewed API counterparts for required specialized operations. Compiler and package execution through the authenticated MCP native job adapter is distinct from forwarding a native entity HTTP API route. Historical command execution and database-assisted inspection or cleanup do not establish strict native-API-only parity. + Use these pinned references and refresh the inventory deliberately when their revisions change: | Evidence | Pinned source / meaning | @@ -68,7 +90,7 @@ Ordinary additions should require validated database rows using existing primiti The authoritative inventory must include every actual exposed list/item resource, mutation and specialized action, including read-only dynamic site/custom-admin resources where present. Do not limit API coverage to the package entity map. For each endpoint capture method, route variables/defaults, controller/model, identifier forms, filters/order/pagination, content type, JSON shape, schema, effects, exact Joomla ACL/field permissions and version constraints. -The installed adapter publishes the reviewed native method/task pairs (`GET displayList/displayItem`, `POST add`, `PATCH/PUT edit`, `DELETE delete`). Specialized tasks, nonstandard identifier rules and routing defaults remain explicit unavailable diagnostics until their native contract is reviewed. A GET method alone never establishes a safe read. Fixed route defaults are preserved, and mutation read-back must match the exact collection, controller and defaults. This does not invent endpoints for distributions without a webservices plugin. +The installed adapter publishes the reviewed native method/task pairs (`GET displayList/displayItem`, `POST add`, `PATCH/PUT edit`, `DELETE delete`) with reviewed numeric, GUID and registered unique-key identifiers. Specialized tasks, unsupported identifier rules and unsafe routing defaults remain explicit unavailable diagnostics until their native contract is reviewed. A GET method alone never establishes a safe read. Fixed route defaults are preserved, and mutation read-back must match the exact collection, controller, defaults and resource identity. This does not invent endpoints for distributions without a webservices plugin. List bindings expose bounded `offset`/`limit`, a scalar `filter` object, and `ordering`/`direction`, mapped to Joomla's native `page[...]`, `filter[...]` and `list[...]` query keys. The installed controller defines accepted filter names and ordering columns; unknown names may be ignored by Joomla. Router registrations do not contain controller-specific filter schemas, so discovery does not claim an invented list of supported fields. Native ACL, validation and form handling remain authoritative. diff --git a/docs/migration/parity.json b/docs/migration/parity.json index 289078a..dc9769c 100644 --- a/docs/migration/parity.json +++ b/docs/migration/parity.json @@ -1,6 +1,6 @@ { "source": "2cff50f4f6b440da3c684f9995a77efad32e1a36", - "runtimeSource": "79df3a13fa43f91c179b3ec0dd21e185c6b56002c42fd580cba91a12443e16b1", + "runtimeSource": "cff50874f34211ee5274c34647feea6a58d7b5fe05058a7948fb0b8f47ce1c03", "tools": [ "joomla_sites_list", "joomla_capabilities", @@ -68,7 +68,9 @@ "joomla_job_artifact_read" ], "runtimeSchemaOverrides": { - "joomla_permission_request": "Permission scopes come from enabled, authorized write actions in the installed database catalogue, including extension providers. The immutable source enum described only the original core catalogue; Permissions remains the authoritative scope and ACL check." + "joomla_permission_request": "Permission scopes come from enabled, authorized write actions in the installed database catalogue, including extension providers. The immutable source enum described only the original core catalogue; Permissions remains the authoritative scope and ACL check.", + "joomla_action_read": "Generic read actions accept bounded JSON-valued arguments, including nested filters and objects declared by installed extension action schemas. The action-specific schema and shared input depth, collection, field-name and byte bounds remain authoritative; routes, handlers and authority remain server controlled.", + "joomla_companion_action_read": "The local companion read envelope accepts the same bounded JSON-valued action arguments as generic reads. Native action schemas, trusted CLI authority and shared input limits still validate every invocation." }, "runtimeBindingSchemaOverrides": { "modules.site.create.api": "Issue #24: make the accepted module create ordering contract explicit before mutation, including native automatic assignment for zero or omitted ordering.", @@ -2088,7 +2090,7 @@ }, "counts": { "provider": 1, - "schema": 413, + "schema": 415, "action": 285, "binding": 467, "tool": 30, diff --git a/tests/Support/JcbApiContracts.php b/tests/Support/JcbApiContracts.php index aaa64a4..a323c5e 100644 --- a/tests/Support/JcbApiContracts.php +++ b/tests/Support/JcbApiContracts.php @@ -50,6 +50,7 @@ $router->createCRUDRoutes('v1/jcb-fixture/a', 'entities', $defaults); $router->createCRUDRoutes('v1/jcb-fixture/b', 'entities', ['component' => 'com_componentbuilder', 'extension' => 'com_fixture_b']); $router->createCRUDRoutes('v1/componentbuilder-false-owner', 'entities', ['component' => 'com_content']); + $router->createCRUDRoutes('v1/compiled-fixture/items', 'items', ['component' => 'com_compiled_fixture']); $router->addRoutes([ new Route(['PUT'], 'v1/jcb-fixture/a/:id', 'entities.edit', ['id' => '(\d+)'], $defaults), new Route(['GET'], 'v1/jcb-fixture/compile', 'compiler.compile', [], $defaults), @@ -59,16 +60,32 @@ new Route(['GET'], 'v1/jcb-fixture/unsafe-default', 'entities.displayList', [], $defaults + ['task' => 'compile']), new Route(['GET'], 'v1/jcb-fixture/collision/:filter', 'entities.displayList', [], $defaults), new Route(['GET'], 'v1/jcb-fixture/numeric-default/:id', 'entities.displayItem', ['id' => '(\d+)'], $defaults + ['id' => 99]), + new Route(['GET'], 'v1/jcb-fixture/a/guid/:guid', 'entities.displayItem', ['guid' => '([0-9a-fA-F-]{36})'], $defaults), + new Route(['PATCH'], 'v1/jcb-fixture/a/guid/:guid', 'entities.edit', ['guid' => '([0-9a-fA-F-]{36})'], $defaults), + new Route(['DELETE'], 'v1/jcb-fixture/a/guid/:guid', 'entities.delete', ['guid' => '([0-9a-fA-F-]{36})'], $defaults), + new Route(['GET'], 'v1/jcb-fixture/a/slug/:slug', 'entities.displayItem', ['slug' => '([^/]+)'], $defaults), + new Route(['PATCH'], 'v1/jcb-fixture/a/slug/:slug', 'entities.edit', ['slug' => '([^/]+)'], $defaults), + new Route(['DELETE'], 'v1/jcb-fixture/a/slug/:slug', 'entities.delete', ['slug' => '([^/]+)'], $defaults), + new Route(['GET'], 'v1/jcb-fixture/b/guid/:guid', 'entities.displayItem', ['guid' => '([0-9a-fA-F-]{36})'], ['component' => 'com_componentbuilder', 'extension' => 'com_fixture_b']), + new Route(['PATCH'], 'v1/jcb-fixture/b/guid/:guid', 'entities.edit', ['guid' => '([0-9a-fA-F-]{36})'], ['component' => 'com_componentbuilder', 'extension' => 'com_fixture_b']), + new Route(['DELETE'], 'v1/jcb-fixture/b/guid/:guid', 'entities.delete', ['guid' => '([0-9a-fA-F-]{36})'], ['component' => 'com_componentbuilder', 'extension' => 'com_fixture_b']), + new Route(['DELETE'], 'v1/jcb-fixture/invalid-guid-delete/:id', 'entities.deleteByGuid', ['id' => '(\d+)'], $defaults), ]); $inventory = (new ApiRegistry($router))->inventory(); - $check(count($inventory['routes']) === 12, 'Every reviewed actual CRUD method/path is inventoried.'); - $check(count($inventory['unsupported']) === 6, 'Unreviewed method/task, variable and routing-default contracts remain explicit.'); + $check(count($inventory['routes']) === 21, 'Every reviewed actual CRUD method/path, GUID and unique-key alias is inventoried.'); + $check(count($inventory['unsupported']) === 7, 'Unreviewed method/task, variable and routing-default contracts remain explicit.'); $check(!str_contains(Json::encode($inventory), 'false-owner'), 'Ownership comes only from native component defaults.'); + $check(!str_contains(Json::encode($inventory), 'compiled-fixture'), 'Other installed components require an explicit reviewed component scope.'); + $inventory = (new ApiRegistry($router, null, ['com_componentbuilder', 'com_compiled_fixture']))->inventory(); + $check(count($inventory['routes']) === 26 && $inventory['components'] === ['com_compiled_fixture', 'com_componentbuilder'], + 'A reviewed generated component uses its observed registrations without adding Joomla core routes.'); $commands = ['commands' => [], 'unsupported' => []]; $seed = (new CatalogueBuilder())->build($commands, $inventory); $check(count($seed['entities']['action']) === count($inventory['routes']), 'Every supported registration produces exactly one action.'); $metadata = Json::decode($seed['entities']['provider'][0]['definition']); $check($metadata['unsupportedRoutes'] === $inventory['unsupported'], 'Unsupported inventory remains visible in provider diagnostics.'); + $check(count($seed['entities']['provider']) === 2 && $seed['entities']['provider'][1]['extension'] === 'com_compiled_fixture', + 'Generated components retain separate provider ownership and extension lifecycle dependencies.'); $bindings = []; $schemas = []; foreach ($seed['entities']['schema'] as $schema) @@ -91,15 +108,21 @@ 'Pagination, native filter and list ordering retain distinct bounded query semantics.'); $check($request['body'] === null && $request['method'] === 'GET', 'Read routes never acquire a mutation body.'); $check(!isset($request['query']['component'], $request['query']['public']), 'Router metadata remains native metadata, not client-selectable input.'); - foreach ([['filter' => ['x][task' => 'compile']], ['filter' => ['search' => ['nested']]], ['filter' => array_fill_keys(range('A', 'Z'), 'x') + array_fill_keys(range('a', 'z'), 'x')], + foreach ([['filter' => ['x][task' => 'compile']], ['filter' => ['search' => [['nested']]]], ['filter' => ['state' => []]], + ['filter' => ['state' => array_fill(0, 65, 1)]], ['filter' => array_fill_keys(range('A', 'Z'), 'x') + array_fill_keys(range('a', 'z'), 'x')], ['filter' => ['search' => str_repeat('x', 2049)]], ['direction' => 'unsafe'], ['ordering' => 'a.title desc'], ['limit' => 501]] as $invalid) { $reject(static fn () => $validator->input($invalid, $schemas[$list['input_schema_id']]), 'INVALID_INPUT'); } - foreach ([['bad][task' => 'compile'], ['search' => []], ['search' => str_repeat('x', 2049)]] as $invalid) + foreach ([['bad][task' => 'compile'], ['search' => [['nested']]], ['search' => []], ['search' => str_repeat('x', 2049)]] as $invalid) { $reject(static fn () => $builder->build(['filter' => $invalid], $list['config']), 'INVALID_INPUT'); } + $multiselect = $validator->input(['filter' => ['state' => [0, 1], 'category' => ['first', 'second']]], $schemas[$list['input_schema_id']]); + $request = $builder->build($multiselect, $list['config']); + $check($request['query']['filter[state][0]'] === 0 && $request['query']['filter[state][1]'] === 1 + && $request['query']['filter[category][0]'] === 'first' && $request['query']['filter[category][1]'] === 'second', + 'Generated multiselect filters preserve bounded native scalar values and selection order.'); foreach (['a', 'b'] as $scope) { $create = $bindings['POST /v1/jcb-fixture/' . $scope]; @@ -117,6 +140,41 @@ $fixedId = $bindings['GET /v1/jcb-fixture/numeric-default/:id']; $request = $builder->build(['id' => 4], $fixedId['config']); $check($request['path'] === '/v1/jcb-fixture/numeric-default/4' && !isset($request['query']['id']), 'Matched route variables override native fallback defaults.'); + $guid = '62C69E4D-9B19-478A-A88A-6F00316FF1DB'; + foreach (['PATCH', 'DELETE'] as $method) + { + foreach (['a', 'b'] as $scope) + { + $alias = $bindings[$method . ' /v1/jcb-fixture/' . $scope . '/guid/:guid']; + $read = $bindings['GET /v1/jcb-fixture/' . $scope . '/guid/:guid']; + $arguments = ['guid' => $guid] + ($method === 'PATCH' ? ['data' => ['title' => 'updated by GUID']] : []); + $validated = $validator->input($arguments, $schemas[$alias['input_schema_id']]); + $request = $builder->build($validated, $alias['config']); + $verification = Json::decode($alias['params'])['verification']; + $check($request['path'] === '/v1/jcb-fixture/' . $scope . '/guid/' . $guid + && $alias['config']['read_action'] . '.api' === $read['name'] + && $verification['primary_key'] === 'guid' && $verification['input_key'] === 'guid' + && $verification['read_input_key'] === 'guid' && $verification['identity_type'] === 'guid', + 'GUID mutations encode the actual alias and bind verification to its exact scoped GUID read.'); + $reject(static fn () => $validator->input(array_replace($arguments, ['guid' => str_repeat('a', 36)]), $schemas[$alias['input_schema_id']]), 'INVALID_INPUT'); + } + } + $slug = $bindings['PATCH /v1/jcb-fixture/a/slug/:slug']; + $request = $builder->build($validator->input(['slug' => '0012 Namibian λ..key', 'data' => ['title' => 'updated by unique key']], + $schemas[$slug['input_schema_id']]), $slug['config']); + $check($request['path'] === '/v1/jcb-fixture/a/slug/' . rawurlencode('0012 Namibian λ..key') + && Json::decode($slug['params'])['verification']['identity_type'] === 'string', + 'Unique-key aliases preserve leading zeros and safely encode one native path segment.'); + foreach (['.', '..', 'bad/path', 'bad\\path', 'bad%2Fpath', 'bad?query', "bad\nkey"] as $unsafe) + { + $reject(static fn () => $validator->input(['slug' => $unsafe, 'data' => ['title' => 'x']], $schemas[$slug['input_schema_id']]), 'INVALID_INPUT'); + } + $compiled = $bindings['PATCH /v1/compiled-fixture/items/:id']; + $definition = Json::decode($compiled['definition']); + $check($definition['required_permissions'] === [['action' => 'core.manage', 'asset' => 'com_compiled_fixture']] + && str_starts_with($compiled['name'], 'jcb.api.compiled_fixture.items.edit.patch.') + && $compiled['provider_id'] === $seed['entities']['provider'][1]['id'], + 'Generated API actions bind their component permission asset and cannot inherit JCB authority.'); $before = $inventory['fingerprint']; $router->addRoutes([new Route(['GET'], 'v1/jcb-fixture/new-special', 'entities.archive', [], $defaults)]); $check((new ApiRegistry($router))->inventory()['fingerprint'] !== $before, 'Unsupported registration changes alter the inventory fingerprint.'); diff --git a/tests/catalogue-refresh.php b/tests/catalogue-refresh.php new file mode 100644 index 0000000..79730de --- /dev/null +++ b/tests/catalogue-refresh.php @@ -0,0 +1,23 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +use VDM\Component\JoomEngineMcp\Administrator\Process\PhpProcess; +use VDM\Component\JoomEngineMcp\Administrator\Service\Json; + +require dirname(__DIR__) . '/admin/autoload.php'; +$process = new PhpProcess(PHP_BINARY, __DIR__ . '/fixtures/catalogue-refresh-child.php', __DIR__); +$result = $process->run([], 15, 16384, static fn (): bool => false); +if (($result['checks'] ?? 0) < 22 || ($result['routes'] ?? null) !== 320 || ($result['fieldsPerContract'] ?? null) !== 60 + || ($result['memoryLimit'] ?? '') !== '128M' || ($result['peakBytes'] ?? PHP_INT_MAX) >= 134217728) +{ + throw new RuntimeException('Full native catalogue refresh must stay fresh, fail closed and fit the fixed128MiB process.'); +} +echo Json::encode(['checks' => $result['checks'] + 1, 'catalogueRefresh' => 'passed', 'routes' => $result['routes'], + 'fieldsPerContract' => $result['fieldsPerContract'], 'firstSnapshotBytes' => $result['firstSnapshotBytes'], + 'peakBytes' => $result['peakBytes'], 'memoryLimit' => $result['memoryLimit']]) . PHP_EOL; diff --git a/tests/catalogue.php b/tests/catalogue.php index 5439d94..2ae9998 100644 --- a/tests/catalogue.php +++ b/tests/catalogue.php @@ -539,3 +539,5 @@ public function runSchedulerTask(int $id): int } echo Json::encode(['checks' => $checks, 'sourceParity' => 'all inventoried tools/actions and native constructor contracts', 'cataloguePolicy' => 'passed', 'encryption' => 'passed', 'liveJoomla' => 'not run by this unit suite']) . PHP_EOL; + +require __DIR__ . '/schema-upgrade.php'; diff --git a/tests/fixtures/catalogue-refresh-child.php b/tests/fixtures/catalogue-refresh-child.php new file mode 100644 index 0000000..598e42c --- /dev/null +++ b/tests/fixtures/catalogue-refresh-child.php @@ -0,0 +1,140 @@ +getIdentifier() === $expected, 'Unexpected catalogue failure: ' . $error->getIdentifier()); + return; + } + throw new RuntimeException('The catalogue operation should have been rejected.'); +}; +$fields = []; +$properties = []; +$verification = []; +for ($index = 0; $index < 60; $index++) +{ + $name = 'native_field_' . $index; + $properties[$name] = ['type' => 'string', 'minLength' => 1, 'maxLength' => 255, 'description' => 'Native source-backed field ' . $index]; + $fields[$name] = ['native_type' => 'list', 'label' => 'COM_EXAMPLE_FIELD_' . $index, + 'required' => false, 'readonly' => false, 'disabled' => false, 'default' => 'first', 'filter' => 'STRING', + 'validation' => '', 'multiple' => false, 'choices' => [ + ['value' => 'first', 'label' => 'COM_EXAMPLE_FIRST', 'disabled' => false], + ['value' => 'second', 'label' => 'COM_EXAMPLE_SECOND', 'disabled' => false], + ['value' => 'third', 'label' => 'COM_EXAMPLE_THIRD', 'disabled' => false]]]; + $verification[$name] = ['representation' => 'string', 'properties' => [], 'numeric' => false]; +} +$form = ['schema' => ['type' => 'object', 'properties' => (object) $properties, 'additionalProperties' => true], + 'fields' => $fields, 'verification' => ['fields' => $verification, 'request_only' => []], 'generation' => [], + 'native_required_adjustments' => false, 'provenance' => ['model' => 'record', 'form' => 'record', + 'sources' => [['path' => 'administrator/forms/record.xml', 'sha256' => hash('sha256', 'native-fixture')]]]]; +$form['fingerprint'] = Json::canonicalHash($form); +$routes = []; +for ($index = 0; $index < 320; $index++) +{ + $routes[] = ['method' => 'GET', 'route' => '/v1/example/records_' . $index . '/:id', 'controller' => 'record.displayItem', + 'defaults' => ['component' => 'com_example'], 'variables' => ['id'], 'rules' => ['id' => '(\\d+)'], + 'required_extensions' => ['webservices/ExampleRoutes'], 'form_contract' => $form]; +} +$seed = (new CatalogueBuilder())->build(['commands' => []], ['routes' => $routes, 'components' => ['com_example']]); +$store = new MemoryStore($seed['entities']); +$names = array_column($seed['entities']['action'], 'name'); +$configuration = $seed['entities']['binding'][159]['configuration']; +unset($seed, $routes, $form, $properties, $fields, $verification); +$principal = new Principal(); +$schemas = new SchemaValidator(); +$extensions = true; +$handlers = true; +$catalogue = new Catalogue($store, new Authorizer(), $principal, $schemas, new Settings(['joomla_version' => '6.1.4']), + static function (string $extension) use (&$extensions): bool { return $extensions; }, + static function (string $entity, string $handler) use (&$handlers): bool { return $handlers; }); +$catalogue->refresh(); +$check(count($catalogue->all('action')) === 320, 'All320 complete native-form routes must be available.'); +$resolved = $catalogue->action($names[0]); +$check(count($resolved['binding']['configuration']['api_form']['fields']) === 60, 'Native form descriptors must remain complete.'); +unset($resolved); +$firstBytes = memory_get_usage(true); +fwrite(STDERR, 'Initial complete320-route snapshot: ' . $firstBytes . " bytes.\n"); +$catalogue->refresh(); +$check(count($catalogue->all('action')) === 320, 'Repeated refresh must retain every native route under128MiB.'); +for ($repeat = 0; $repeat < 3; $repeat++) +{ + $catalogue->refresh(); + $check(count($catalogue->all('action')) === 320, 'Persistent refresh cannot accumulate full native-form snapshots.'); +} +$store->update('action', ['title' => 'Administrator changed title', 'name' => 'administrator.changed.action'], ['id' => 1]); +$catalogue->refresh(); +$check($catalogue->get('action', 'administrator.changed.action')['title'] === 'Administrator changed title', 'Changed names and metadata must be read from current rows.'); +$reject(static fn () => $catalogue->get('action', $names[0]), 'DEFINITION_UNAVAILABLE'); +$binding = $catalogue->action('administrator.changed.action')['binding']; +$schemaId = (int) $binding['input_schema_id']; +unset($binding); +$store->update('schema', ['document' => '{"type":"object","properties":{"id":{"type":"integer","maximum":7}},"required":["id"]}', 'customized' => 1], ['id' => $schemaId]); +$catalogue->refresh(); +$current = $catalogue->action('administrator.changed.action'); +$check($schemas->input(['id' => 7], $catalogue->schema($schemaId)) === ['id' => 7], 'Customized current input schemas must remain effective.'); +$reject(static fn () => $schemas->input(['id' => 8], $catalogue->schema($schemaId)), 'INVALID_INPUT'); +unset($current); +$extensions = false; +$catalogue->refresh(); +$check($catalogue->all('action') === [], 'Installed extension revocation must invalidate cached visibility and bindings.'); +$extensions = true; +$handlers = false; +$catalogue->refresh(); +$check($catalogue->all('action') === [], 'Registered handler revocation must invalidate cached execution availability.'); +$handlers = true; +$catalogue->refresh(); +$check(count($catalogue->all('action')) === 320, 'Fresh extension and handler observations must restore all available routes.'); +$beforeBinding = $store->one('binding', ['id' => 160]); +$changedConfiguration = Json::decode($configuration); +$changedConfiguration['administrator_note'] = 'Current raw administrator configuration'; +$configuration = Json::encode($changedConfiguration); +$store->update('binding', ['configuration' => $configuration], ['id' => 160]); +$catalogue->refresh(); +$changed = $catalogue->action($names[159])['binding']; +$check($changed['configuration']['administrator_note'] === 'Current raw administrator configuration' + && $changed['version'] === $beforeBinding['version'] && $changed['seed_hash'] === $beforeBinding['seed_hash'], + 'Raw configuration edits are refreshed even when version and installer ownership markers are unchanged.'); +unset($beforeBinding, $changedConfiguration, $changed); +$store->update('binding', ['configuration' => '{invalid-json'], ['id' => 160]); +$reject(static fn () => $catalogue->refresh(), 'INVALID_JSON'); +$reject(static fn () => $catalogue->get('action', 'administrator.changed.action'), 'INVALID_JSON'); +$reject(static fn () => $catalogue->all('action'), 'INVALID_JSON'); +$reject(static fn () => $catalogue->action($names[319]), 'INVALID_JSON'); +$store->update('binding', ['configuration' => $configuration], ['id' => 160]); +$store->update('action', ['access' => 9], ['id' => 1]); +$reject(static fn () => $catalogue->get('action', 'administrator.changed.action'), 'DEFINITION_UNAVAILABLE'); +$check(count($catalogue->all('action')) === 319, 'Recovery must read current rows rather than restoring a previously authorized snapshot.'); +$principal->deny('mcp.execute', 'com_joomengine_mcp.provider.1'); +$catalogue->refresh(); +$check($catalogue->all('action') === [], 'Fresh principal permission revocation must hide all native routes.'); +$check(ini_get('memory_limit') === '128M', 'The fixed process must keep the real128MiB budget.'); +echo Json::encode(['protocol' => 'joomengine-worker/1', 'checks' => $checks, 'routes' => 320, 'fieldsPerContract' => 60, + 'firstSnapshotBytes' => $firstBytes, 'peakBytes' => memory_get_peak_usage(true), 'memoryLimit' => ini_get('memory_limit')]) . PHP_EOL; diff --git a/tests/fixtures/inventory-child.php b/tests/fixtures/inventory-child.php new file mode 100644 index 0000000..a0a0fa8 --- /dev/null +++ b/tests/fixtures/inventory-child.php @@ -0,0 +1,55 @@ + InventoryTransport::MAX_ROUTES) +{ + exit(1); +} +$schema = ['type' => 'object', 'properties' => (object) [ + 'title' => ['type' => 'string', 'minLength' => 1, 'description' => str_repeat('Native field evidence. ', 6000)], + 'custom' => (object) [], +], 'required' => ['title'], 'additionalProperties' => true]; +$form = ['schema' => $schema, 'fields' => ['title' => ['native_type' => 'text', 'required' => true]], + 'verification' => ['fields' => ['title' => [], 'rows' => ['representation' => 'json', + 'items' => ['representation' => 'object', 'properties' => (object) ['count' => ['numeric' => true]]]]], 'request_only' => []], + 'generation' => [], 'native_required_adjustments' => false, + 'provenance' => ['model' => 'record', 'form' => 'record', 'sources' => [ + ['path' => 'administrator/forms/record.xml', 'sha256' => hash('sha256', 'native-source')]]], + 'empty_object' => (object) [], 'empty_list' => [], 'numeric_object' => (object) ['0' => 1.0, '1' => 2]]; +$forms = []; +foreach (['POST', 'PATCH'] as $method) +{ + $contract = $form; + if ($method !== 'POST') + { + unset($contract['schema']['required']); + } + $contract['fingerprint'] = hash('sha256', Json::canonical($contract)); + $forms[$method] = $contract; +} +$routes = []; +for ($index = 0; $index < $count; $index++) +{ + $method = $index % 2 === 0 ? 'POST' : 'PATCH'; + $routes[] = ['method' => $method, 'route' => '/v1/example/records_' . $index . ($method === 'PATCH' ? '/:id' : ''), + 'controller' => 'record.' . ($method === 'POST' ? 'add' : 'edit'), + 'defaults' => ['component' => 'com_example', 'public' => false, 'rate' => 1.0], + 'variables' => $method === 'POST' ? [] : ['id'], + 'rules' => $method === 'POST' ? [] : ['id' => '(\\d+)'], + 'required_extensions' => ['webservices/ExampleRoutes'], 'form_contract' => $forms[$method]]; +} +$api = ['routes' => $routes, 'components' => ['com_example'], 'unsupported' => ['PUT /v1/example/custom' => 'Reviewed adapter required.'], + 'unsupported_components' => ['PUT /v1/example/custom' => 'com_example'], + 'native_observation' => ['empty' => (object) [], 'numeric' => (object) ['0' => 1.0]]]; +$api['fingerprint'] = Json::canonicalHash(['routes' => $routes, 'unsupported' => $api['unsupported'], 'components' => $api['components']]); +$result = ['protocol' => 'joomengine-worker/1', 'commands' => ['commands' => [], 'unsupported' => []], 'api' => $api]; +if (($input['inventory_format'] ?? null) === InventoryTransport::FORMAT) +{ + $result = InventoryTransport::pack($result); +} +echo json_encode($result, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR); diff --git a/tests/fixtures/jcb/README.md b/tests/fixtures/jcb/README.md new file mode 100644 index 0000000..3febcaa --- /dev/null +++ b/tests/fixtures/jcb/README.md @@ -0,0 +1,9 @@ +# Full supplied JCB API fixture + +`full-api-componentbuilder-6.1.6.zip.001` and `.002` are consecutive binary parts of the exact component ZIP supplied on 2 October 2026, originally named `com_componentbuilder_v6_1_6__J6(1).zip`. Concatenation is lossless; no generated API, administrator form or model file was edited. The reconstructed ZIP is 14,597,657 bytes and its SHA-256 is `a5870a1b162c9db1f8c23b87562389c46260838f471914132ef1d3d66fb8f55a`. The golden runner verifies this before Joomla's native extension installer installs it. + +The package contains 102 form-backed API controllers for 51 CRUD resource families, plus the read-only Compiler and Extrusion controllers. The supplied historical routing plugin was not included. `tests/golden-image/full-api-plugin.php` therefore builds a separate, explicitly disposable routing plugin with the package's actual JCB compiler `Architecture/Api/Plugin/Routes.php` renderer. The renderer receives resource roles and unique keys projected from the installed controllers. Its native rules produce 318 form-backed CRUD registrations (including 63 GUID routes), plus two read-only registrations. This is compiler-rendered fixture provenance, not a claim that the historical linked plugin was supplied or installed. + +The final golden phase installs the full package and routing fixture after the existing pinned-source compiler/package/remote-client scenarios. It exercises the actual isolated inventory worker and the real installed `joomla:mcp:jcb-sync` command twice. The measured complete inventory contains native command contracts and the full installed API form metadata; it must exceed the previous 8 MiB envelope without padding. Compact inventory IPC must remain below that existing envelope bound. + +Acceptance checks exact installed contract-source bytes, all 320 registrations, all 51 form bindings, every persisted schema/action/binding contract and its production schema parser, authenticated MCP discovery, existing Joomla core forwarding, preservation of all existing core definitions, and native operator customization across the second sync. All graph rows belong to the disposable site; its existing native component uninstall and stack teardown clean them up. This regression establishes full-size discovery and synchronization, not 51-family CRUD acceptance or resolution of upstream field-validation policy. diff --git a/tests/fixtures/jcb/full-api-componentbuilder-6.1.6.zip.001 b/tests/fixtures/jcb/full-api-componentbuilder-6.1.6.zip.001 new file mode 100644 index 0000000..cf3ad24 Binary files /dev/null and b/tests/fixtures/jcb/full-api-componentbuilder-6.1.6.zip.001 differ diff --git a/tests/fixtures/jcb/full-api-componentbuilder-6.1.6.zip.002 b/tests/fixtures/jcb/full-api-componentbuilder-6.1.6.zip.002 new file mode 100644 index 0000000..696322e Binary files /dev/null and b/tests/fixtures/jcb/full-api-componentbuilder-6.1.6.zip.002 differ diff --git a/tests/fixtures/jcb/native-routing-provenance.json b/tests/fixtures/jcb/native-routing-provenance.json new file mode 100644 index 0000000..734de5f --- /dev/null +++ b/tests/fixtures/jcb/native-routing-provenance.json @@ -0,0 +1,25 @@ +{ + "fixture": "Compiler-rendered routing fixture for the supplied full JCB API; the historical linked webservices plugin is absent from this package.", + "package": "com_componentbuilder_v6_1_6__J6(1).zip", + "package_sha256": "a5870a1b162c9db1f8c23b87562389c46260838f471914132ef1d3d66fb8f55a", + "package_bytes": 14597657, + "package_entries": 6437, + "controller_digest_format": "Sorted package-relative path, NUL, SHA256 of file bytes, LF.", + "controller_sha256": "f2aa066598ddf22ae34cb10ef65f553b679c55b1fdbca65edea986fe24ac3926", + "controllers": 104, + "administrator_pairs": 51, + "guid_items": 21, + "readonly_lists": [ + "compiler", + "extrusion" + ], + "native_routes": 320, + "native_methods": { + "GET": 125, + "POST": 51, + "PATCH": 72, + "DELETE": 72 + }, + "renderer": "libraries/vendor_jcb/VDM.Joomla/src/Componentbuilder/Compiler/Architecture/Api/Plugin/Routes.php", + "projection": "Literal controller contentType/default_view, item getRecordId unique-key literals, and dynamic Administrator model binding. Native Resources and RecordId determine routing." +} diff --git a/tests/generated-api-catalogue.php b/tests/generated-api-catalogue.php new file mode 100644 index 0000000..074dc1b --- /dev/null +++ b/tests/generated-api-catalogue.php @@ -0,0 +1,181 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; +use VDM\Component\JoomEngineMcp\Administrator\Handler\ApiRequestBuilder; +use VDM\Component\JoomEngineMcp\Administrator\Installer\SeedUpdater; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\ApiRegistry; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\CatalogueBuilder; +use VDM\Component\JoomEngineMcp\Administrator\Security\Authorizer; +use VDM\Component\JoomEngineMcp\Administrator\Security\SchemaValidator; +use VDM\Component\JoomEngineMcp\Administrator\Service\Catalogue; +use VDM\Component\JoomEngineMcp\Administrator\Service\Json; +use VDM\Component\JoomEngineMcp\Administrator\Service\Settings; +use VDM\Component\JoomEngineMcp\Tests\Support\MemoryStore; +use VDM\Component\JoomEngineMcp\Tests\Support\Principal; + +require dirname(__DIR__) . '/admin/autoload.php'; +require __DIR__ . '/Support/MemoryStore.php'; +require __DIR__ . '/Support/Principal.php'; + +$checks = 0; +$check = static function (bool $condition, string $message) use (&$checks): void +{ + if (!$condition) + { + throw new RuntimeException($message); + } + $checks++; +}; +$reject = static function (callable $operation, string $identifier) use ($check): void +{ + try + { + $operation(); + } + catch (OperationException $exception) + { + $check($exception->getIdentifier() === $identifier, 'Expected ' . $identifier . ', got ' . $exception->getIdentifier()); + return; + } + throw new RuntimeException('Expected rejection ' . $identifier); +}; +$registration = static function (string $component, string $path, string $task, string $method, array $rules = []): array +{ + return ['method' => $method, 'route' => $path, 'controller' => 'record.' . $task, + 'variables' => array_keys($rules), 'rules' => $rules, 'defaults' => ['component' => $component, 'extension' => 'fixed-scope'], + 'required_extensions' => ['webservices/GeneratedFixture']]; +}; +$routes = []; +foreach (['com_componentbuilder', 'com_example', 'com_other'] as $component) +{ + $path = '/v1/' . substr($component, 4) . '/records'; + $routes[] = $registration($component, $path, 'add', 'POST'); + foreach (['id' => '(\\d+)', 'guid' => '([0-9a-fA-F-]{36})', 'external_key' => '([^/]+)'] as $variable => $rule) + { + $itemPath = $path . ($variable === 'id' ? '/:id' : '/' . $variable . '/:' . $variable); + foreach (['GET' => 'displayItem', 'PATCH' => 'edit', 'DELETE' => 'delete'] as $method => $task) + { + $routes[] = $registration($component, $itemPath, $task, $method, [$variable => $rule]); + } + } +} +$form = ['schema' => ['type' => 'object', 'properties' => ['relation_guid' => ['type' => 'string', + 'pattern' => '^[0-9a-fA-F]{8}(?:-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$'], 'php_code' => ['type' => 'string']], + 'required' => ['relation_guid'], 'additionalProperties' => true], + 'fields' => ['relation_guid' => ['native_type' => 'text', 'validation' => 'guid']], + 'verification' => ['fields' => ['relation_guid' => ['representation' => 'string']], 'request_only' => ['not_required']], + 'provenance' => ['controller' => 'api/src/Controller/RecordController.php', 'form' => 'admin/forms/record.xml']]; +foreach ($routes as &$route) +{ + if ($route['method'] === 'POST' && $route['defaults']['component'] === 'com_example') + { + $route['form_contract'] = $form; + } +} +unset($route); +$builder = new CatalogueBuilder(); +$seed = $builder->build(['commands' => [], 'unsupported' => []], ['routes' => $routes, + 'components' => ['com_componentbuilder', 'com_example', 'com_other']]); +$check(count($seed['entities']['provider']) === 3 && count($seed['entities']['action']) === 30, + 'Observed component contracts produce separate providers and one action per registered route.'); +$schemas = []; +$validator = new SchemaValidator(); +foreach ($seed['entities']['schema'] as $schema) +{ + $validator->document($schema['document']); + $schemas[$schema['id']] = $schema['document']; + $checks++; +} +$bindings = []; +foreach ($seed['entities']['binding'] as $binding) +{ + $config = Json::decode($binding['configuration']); + $bindings[$config['method'] . ' ' . $config['route']] = $binding + ['config' => $config, 'verification' => Json::decode($binding['params'])['verification'] ?? []]; +} +$guid = '62c69e4d-9b19-478a-a88a-6f00316ff1db'; +$requestBuilder = new ApiRequestBuilder(); +foreach (['componentbuilder', 'example', 'other'] as $code) +{ + $path = '/v1/' . $code . '/records'; + $create = $bindings['POST ' . $path]; + $numeric = $bindings['GET ' . $path . '/:id']; + $check($create['config']['read_action'] . '.api' === $numeric['name'] + && $create['verification']['primary_key'] === 'id' && $create['verification']['identity_type'] === 'integer', + 'Create readback selects its exact observed numeric item rather than an arbitrary GUID alias.'); + foreach (['guid' => [$guid, 'guid'], 'external_key' => ['0012 λ key', 'string']] as $key => [$value, $type]) + { + $itemPath = $path . '/' . $key . '/:' . $key; + $write = $bindings['PATCH ' . $itemPath]; + $read = $bindings['GET ' . $itemPath]; + $input = $validator->input([$key => $value, 'data' => ['php_code' => 'build($input, $write['config']); + $check($request['path'] === $path . '/' . $key . '/' . rawurlencode($value) + && $write['config']['read_action'] . '.api' === $read['name'] + && $write['verification']['primary_key'] === $key && $write['verification']['read_input_key'] === $key + && $write['verification']['identity_type'] === $type && str_contains($request['body']['php_code'], ' $validator->input(['data' => ['php_code' => 'source']], $schemas[$create['input_schema_id']]), 'INVALID_INPUT'); +$reject(static fn () => $validator->input(['data' => ['relation_guid' => 'invalid']], $schemas[$create['input_schema_id']]), 'INVALID_INPUT'); +$valid = $validator->input(['data' => ['relation_guid' => $guid, 'php_code' => 'build($valid, $create['config'])['body']['relation_guid'] === $guid, + 'Actual native form validation reaches the complete generated action schema.'); +$guidOnly = array_filter($routes, static fn (array $route): bool => $route['defaults']['component'] === 'com_other' + && ($route['variables'] === ['guid'] || $route['method'] === 'POST')); +$guidSeed = $builder->build(['commands' => []], ['routes' => array_values($guidOnly)]); +$guidCreate = array_values(array_filter($guidSeed['entities']['binding'], static fn (array $binding): bool => Json::decode($binding['configuration'])['method'] === 'POST'))[0]; +$check(Json::decode($guidCreate['params'])['verification']['primary_key'] === 'guid' + && Json::decode($guidCreate['params'])['verification']['identity_type'] === 'guid', + 'A GUID-only native resource can verify creation without inventing a numeric route.'); +$unreviewed = $registration('com_example', '/v1/example/records/guid/:guid', 'deleteByGuid', 'DELETE', ['guid' => '([0-9a-fA-F-]{36})']); +$check(ApiRegistry::unsupportedReason($unreviewed) !== null, 'An unregistered specialized task name never inherits native CRUD authority.'); +$store = new MemoryStore(); +(new SeedUpdater($store))->apply($seed); +$principal = new Principal(); +$principal->deny('core.manage', 'com_example'); +$enabled = ['com_componentbuilder' => true, 'com_example' => true, 'com_other' => true, 'webservices/GeneratedFixture' => true]; +$catalogue = new Catalogue($store, new Authorizer(), $principal, $validator, new Settings(['joomla_version' => '6.1.3']), + static function (string $extension) use (&$enabled): bool { return $enabled[$extension] ?? false; }, + static fn (string $entity, string $handler): bool => true); +$check(count($catalogue->all('action')) === 20, 'Denying one generated component hides only that component, without inheriting JCB access.'); +$reject(static fn () => $catalogue->action(substr($create['name'], 0, -4)), 'DEFINITION_UNAVAILABLE'); +$enabled['webservices/GeneratedFixture'] = false; +$catalogue->refresh(); +$check($catalogue->all('action') === [], 'The observed webservices owner remains a required runtime extension for every generated action.'); +$empty = $builder->build(['commands' => []], ['routes' => [], 'components' => ['com_example']]); +$check(count($empty['entities']['provider']) === 1 && $empty['entities']['provider'][0]['extension'] === 'com_example' + && $empty['entities']['action'] === [], + 'An explicit empty installed component scope retains provider identity and manufactures no routes.'); +$jcb = $store->one('provider', ['name' => 'jcb.installed']); +$jcbActions = array_values(array_filter($store->find('action'), static fn (array $row): bool => + $row['provider_id'] === $jcb['id'])); +$jcbBefore = Json::canonical($jcbActions); +$updated = (new SeedUpdater($store))->apply($empty); +$check($updated['retired'] > 0 && array_filter($store->find('action'), static fn (array $row): bool => + $row['provider_id'] === $create['provider_id'] && (int) $row['published'] === 1) === [], + 'Resync retires removed routes only for providers in the observed component scope.'); +$jcbAfter = array_values(array_filter($store->find('action'), static fn (array $row): bool => + $row['provider_id'] === $jcb['id'])); +$check(count($jcbAfter) === 10 && array_filter($jcbAfter, static fn (array $row): bool => (int) $row['published'] !== 1) === [] + && Json::canonical($jcbAfter) === $jcbBefore && $store->one('provider', ['name' => 'jcb.installed']) === $jcb, + 'An example-only resync preserves every JCB action and its unselected provider unchanged.'); +$commandScope = $builder->build(['commands' => [], 'component' => 'com_componentbuilder'], ['routes' => [], 'components' => []]); +$check(count($commandScope['entities']['provider']) === 1 && $commandScope['entities']['provider'][0]['extension'] === 'com_componentbuilder', + 'An explicit authoritative empty command scope can synchronize the JCB provider without inventing commands.'); +$legacy = $builder->build(['commands' => []], ['routes' => []]); +$check(count($legacy['entities']['provider']) === 1 && $legacy['entities']['provider'][0]['extension'] === 'com_componentbuilder', + 'Legacy inventory without an explicit component scope retains its existing JCB synchronization behavior.'); +$reject(static fn () => $builder->build(['commands' => []], ['routes' => [$routes[0]], 'components' => ['com_example']]), + 'JCB_INVENTORY_INVALID'); + +echo 'Generated API catalogue, identity and component authority contracts passed: ' . $checks . PHP_EOL; diff --git a/tests/generated-api-inventory.php b/tests/generated-api-inventory.php new file mode 100644 index 0000000..0b4b406 --- /dev/null +++ b/tests/generated-api-inventory.php @@ -0,0 +1,187 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; +use VDM\Component\JoomEngineMcp\Administrator\Database\Structure; +use VDM\Component\JoomEngineMcp\Administrator\Installer\SeedUpdater; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\CatalogueBuilder; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\CatalogueSynchronizer; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\GeneratedApiInventory; +use VDM\Component\JoomEngineMcp\Administrator\Security\SchemaValidator; +use VDM\Component\JoomEngineMcp\Administrator\Service\Json; +use VDM\Component\JoomEngineMcp\Tests\Support\MemoryStore; +use VDM\Component\JoomEngineMcp\Tests\Support\Principal; + +require dirname(__DIR__) . '/admin/autoload.php'; +require __DIR__ . '/Support/MemoryStore.php'; +require __DIR__ . '/Support/Principal.php'; + +$checks = 0; +$check = static function (bool $condition, string $message) use (&$checks): void +{ + if (!$condition) + { + throw new RuntimeException($message); + } + $checks++; +}; +$extensions = [ + ['type' => 'component', 'element' => 'com_componentbuilder', 'enabled' => 1, 'protected' => 0, 'locked' => 0], + ['type' => 'component', 'element' => 'com_example', 'enabled' => '1', 'protected' => '0', 'locked' => '0'], + ['type' => 'component', 'element' => 'com_content', 'enabled' => 1, 'protected' => 1, 'locked' => 1], + ['type' => 'component', 'element' => 'com_contact', 'enabled' => 1, 'protected' => 0, 'locked' => 1], + ['type' => 'component', 'element' => 'com_banners', 'enabled' => '1', 'protected' => '0', 'locked' => '1'], + ['type' => 'component', 'element' => 'com_joomengine_mcp', 'enabled' => 1, 'protected' => 0, 'locked' => 0], + ['type' => 'component', 'element' => 'com_disabled', 'enabled' => 0, 'protected' => 0, 'locked' => 0], + ['type' => 'component', 'element' => 'com_unknown', 'protected' => 0, 'locked' => 0], + ['type' => 'plugin', 'element' => 'com_plugin', 'enabled' => 1, 'protected' => 0, 'locked' => 0], + ['type' => 'component', 'element' => 'com_../escape', 'enabled' => 1, 'protected' => 0, 'locked' => 0], +]; +$coreComponents = ['com_content', 'com_contact', 'com_banners']; +$check(GeneratedApiInventory::components($extensions, $coreComponents) === ['com_componentbuilder', 'com_example'], + 'Native core identity excludes Joomla core, disabled, malformed and MCP components.'); +$check(GeneratedApiInventory::components(array_reverse($extensions), $coreComponents) === GeneratedApiInventory::components($extensions, $coreComponents), + 'Component scope is stable independently of native registry row order.'); +$check(GeneratedApiInventory::components([['type' => 'component', 'element' => 'com_example', 'enabled' => 1]], $coreComponents) === ['com_example'], + 'A compiled component API can be inventoried without JCB being installed.'); +$check(GeneratedApiInventory::components([ + ['type' => 'component', 'element' => 'com_contact', 'enabled' => 1, 'protected' => 0, 'locked' => 1], + ['type' => 'component', 'element' => 'com_banners', 'enabled' => 1, 'protected' => 0, 'locked' => 1], +], $coreComponents) === [], 'Native Joomla core identities never acquire duplicate generated API providers even when protected is zero.'); +$check(GeneratedApiInventory::components([ + ['type' => 'component', 'element' => 'com_locked_custom', 'enabled' => 1, 'protected' => 0, 'locked' => 1], + ['type' => 'component', 'element' => 'com_protected_custom', 'enabled' => 1, 'protected' => 1, 'locked' => 1], +], $coreComponents) === ['com_locked_custom', 'com_protected_custom'], + 'Native uninstall protection and locking do not exclude API-enabled third-party components.'); +$temporary = sys_get_temp_dir() . '/mcp-installed-inventory-' . bin2hex(random_bytes(8)); +$write = static function (string $path, string $source) use ($temporary): void +{ + $absolute = $temporary . '/' . $path; + if (!is_dir(dirname($absolute))) + { + mkdir(dirname($absolute), 0700, true); + } + file_put_contents($absolute, $source); +}; +$remove = static function (string $path) use (&$remove): void +{ + if (is_dir($path) && !is_link($path)) + { + foreach (new DirectoryIterator($path) as $entry) + { + if (!$entry->isDot()) + { + $remove($entry->getPathname()); + } + } + rmdir($path); + } + elseif (file_exists($path) || is_link($path)) + { + unlink($path); + } +}; +try +{ + $write('api/com_example/src/Controller/RecordController.php', 'loadForm("com_example.record", "record", []); } }'); + $write('administrator/com_example/forms/record.xml', '
'); + $route = ['method' => 'POST', 'route' => '/v1/example/records', 'controller' => 'record.add', + 'defaults' => ['component' => 'com_example'], 'variables' => [], 'rules' => [], + 'required_extensions' => ['webservices/ExampleRoutes']]; + $inventory = ['routes' => [$route], 'unsupported' => [], 'components' => ['com_example']]; + $enriched = GeneratedApiInventory::enrich($inventory, $temporary . '/administrator', $temporary . '/api'); + $contract = $enriched['routes'][0]['form_contract'] ?? null; + $check($contract !== null && $contract['provenance']['model'] === 'record' + && isset($contract['fields']['title'], $contract['fields']['active']), + 'Production parent-directory inventory resolves the exact route owner component form.'); + $check(isset($contract['schema']['required']) && in_array('title', $contract['schema']['required'], true), + 'Source-backed native create requirements reach synchronized metadata.'); + $check(($contract['fields']['active']['default'] ?? null) === '0' + && !array_key_exists('default', (array) $contract['schema']['properties']['active']), + 'Native XML defaults remain descriptive metadata instead of implicit request mutations.'); + $validator = new SchemaValidator(); + $validated = $validator->input(['title' => 'literal source'], Json::encode($contract['schema'])); + $check($validated === ['title' => 'literal source'], 'Native schema validation does not insert omitted form fields.'); + $check($enriched['fingerprint'] === GeneratedApiInventory::enrich($inventory, $temporary . '/administrator', $temporary . '/api')['fingerprint'], + 'Unchanged installed native sources produce a stable inventory fingerprint.'); + $other = $route; + $other['defaults']['component'] = 'com_other'; + $other['route'] = '/v1/other/records'; + $missing = GeneratedApiInventory::enrich($inventory + [], $temporary . '/missing', $temporary . '/api'); + $check(isset($missing['routes'][0]['form_contract_status']) && !isset($missing['routes'][0]['form_contract']), + 'Unavailable native form metadata is explicitly disclosed without pretending field validation coverage.'); + $isolated = GeneratedApiInventory::enrich(['routes' => [$other], 'components' => ['com_other']], $temporary . '/administrator', $temporary . '/api'); + $check(!isset($isolated['routes'][0]['form_contract']), 'Another route owner cannot borrow a sibling component form.'); + $write('administrator/com_example/forms/record.xml', ']>
'); + $invalid = GeneratedApiInventory::enrich($inventory, $temporary . '/administrator', $temporary . '/api'); + $check($invalid['routes'] === [] && isset($invalid['unsupported']['POST /v1/example/records']) + && $invalid['unsupported_components']['POST /v1/example/records'] === 'com_example' + && $invalid['fingerprint'] !== $enriched['fingerprint'], + 'Unsafe bound form sources remove executable coverage and retain explicit owner diagnostics.'); + $store = new MemoryStore(); + $assets = 0; + $synchronizer = new CatalogueSynchronizer($store, static function () use (&$assets): void { $assets++; }); + $principal = new Principal(); + $principal->deny('core.admin', 'com_componentbuilder'); + $result = $synchronizer->synchronize(['commands' => []], ['routes' => [], 'components' => ['com_example']], $principal); + $check($result['apiRoutes'] === 0 && $assets === 1, 'Custom component synchronization does not require permission to an absent or unselected JCB.'); + $jcbRoute = $route; + $jcbRoute['defaults']['component'] = 'com_componentbuilder'; + $jcbRoute['route'] = '/v1/componentbuilder/records'; + $jcbSeed = (new CatalogueBuilder())->build(['commands' => []], ['routes' => [$jcbRoute], 'components' => ['com_componentbuilder']]); + (new SeedUpdater($store))->apply($jcbSeed); + $snapshot = static function () use ($store): string + { + $entities = []; + foreach (array_keys(Structure::definitions()) as $entity) + { + $entities[$entity] = $store->find($entity); + } + return Json::canonical($entities); + }; + $commandBefore = $snapshot(); + foreach ([['commands' => [], 'unsupported' => ['componentbuilder:fixture:unreviewed' => 'Requires a reviewed adapter.']], + ['commands' => [], 'component' => 'com_componentbuilder']] as $commands) + { + try + { + $synchronizer->synchronize($commands, ['routes' => [], 'components' => ['com_example']], $principal); + $check(false, 'Command provider synchronization without JCB administration must be rejected.'); + } + catch (OperationException $error) + { + $check($error->getIdentifier() === 'JCB_CATALOGUE_DENIED' && $assets === 1 && $snapshot() === $commandBefore, + 'Unsupported-only and authoritative-empty command scopes require JCB administration before every catalogue mutation.'); + } + } + $jcbProvider = $store->one('provider', ['name' => 'jcb.installed']); + $jcbActions = $store->find('action', ['provider_id' => $jcbProvider['id']]); + $check(count($jcbActions) === 1 && (int) $jcbActions[0]['published'] === 1, + 'Rejected unsupported-only synchronization cannot retire existing JCB actions.'); + $before = Json::canonical($store->find('provider')); + $principal->deny('core.admin', 'com_example'); + try + { + $synchronizer->synchronize(['commands' => []], ['routes' => [], 'components' => ['com_example']], $principal); + $check(false, 'Selected component administration denial must reject synchronization.'); + } + catch (OperationException $error) + { + $check($error->getIdentifier() === 'JCB_CATALOGUE_DENIED' && $assets === 1 + && Json::canonical($store->find('provider')) === $before, + 'Native component administration denial rejects synchronization before any catalogue mutation.'); + } +} +finally +{ + $remove($temporary); +} + +echo 'Installed generated API inventory and form ownership contracts passed: ' . $checks . PHP_EOL; diff --git a/tests/generated-api-transport.php b/tests/generated-api-transport.php new file mode 100644 index 0000000..2e73a5a --- /dev/null +++ b/tests/generated-api-transport.php @@ -0,0 +1,235 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +use Nyholm\Psr7\Response; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; +use VDM\Component\JoomEngineMcp\Administrator\Handler\ApiHandler; +use VDM\Component\JoomEngineMcp\Administrator\Handler\ApiRequestBuilder; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\CatalogueBuilder; +use VDM\Component\JoomEngineMcp\Administrator\Security\SchemaValidator; +use VDM\Component\JoomEngineMcp\Administrator\Service\Json; +use VDM\Component\JoomEngineMcp\Administrator\Service\Settings; +use VDM\Component\JoomEngineMcp\Tests\Support\Principal; + + +require dirname(__DIR__) . '/admin/autoload.php'; +require __DIR__ . '/Support/Principal.php'; +set_error_handler(static function (int $severity, string $message, string $file, int $line): never +{ + throw new ErrorException($message, 0, $severity, $file, $line); +}); +$checks = 0; +$check = static function (bool $value, string $message) use (&$checks): void +{ + $checks++; + if (!$value) + { + throw new RuntimeException($message); + } +}; +$reject = static function (callable $call, string $code = 'INVALID_INPUT') use ($check): void +{ + try + { + $call(); + } + catch (OperationException $error) + { + $check($error->getIdentifier() === $code, 'Expected ' . $code . ', got ' . $error->getIdentifier()); + return; + } + throw new RuntimeException('Expected rejection ' . $code); +}; + +$builder = new ApiRequestBuilder(); +$guid = 'ABCDEF01-2345-6789-abcd-EF0123456789'; +$guidRoute = ['method' => 'GET', 'route' => '/v1/generated/items/guid/:guid', + 'route_parameters' => [['name' => 'guid', 'kind' => 'guid', 'maximumLength' => 36]]]; +$request = $builder->build(['guid' => $guid], $guidRoute); +$check($request['path'] === '/v1/generated/items/guid/' . $guid, 'GUID binding changed the supplied identity or its case.'); + +foreach (['', 1, null, [], str_repeat('a', 36), 'abcdef01-2345-6789-abcd-ef012345678', + 'abcdef01-2345-6789-abcd-ef01234567890', 'abcdef01-2345-6789-abcd-ef012345678g', + $guid . '/other', '%' . $guid, '{' . $guid . '}'] as $invalid) +{ + $reject(static fn () => $builder->build(['guid' => $invalid], $guidRoute)); +} + +$keyRoute = ['method' => 'GET', 'route' => '/v1/generated/items/key/:key', + 'route_parameters' => [['name' => 'key', 'kind' => 'unique-key', 'maximumLength' => 255]]]; + +foreach (['alpha', 'a key', 'alpha..beta', 'name:value', 'München', 'literal+$value', str_repeat('x', 255)] as $key) +{ + $request = $builder->build(['key' => $key], $keyRoute); + $encoded = rawurlencode($key); + $check($request['path'] === '/v1/generated/items/key/' . $encoded + && rawurldecode(basename($request['path'])) === $key, 'Unique-key route lost a literal segment or changed its value.'); +} + +foreach (['', '.', '..', '../other', 'other/child', 'other\\child', '%2fother', '%252fother', + 'key?query', 'key#fragment', "key\nheader", "key\0", "key\x7f", str_repeat('x', 256), 1, [], null] as $invalid) +{ + $reject(static fn () => $builder->build(['key' => $invalid], $keyRoute)); +} + +// Strict wire schemas retain stdClass objects for nested arguments. Both the +// schema and the transport must agree without admitting filter lists or trees. +$schemas = new SchemaValidator(); +$listSchema = Json::encode(['type' => 'object', 'additionalProperties' => false, 'properties' => [ + 'offset' => ['type' => 'integer', 'minimum' => 0, 'maximum' => 100000], + 'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => 500], + 'filter' => ['type' => 'object', 'maxProperties' => 32, 'additionalProperties' => ['type' => ['string', 'integer', 'number', 'boolean']]], +]]); +$listRoute = ['method' => 'GET', 'route' => '/v1/generated/items', 'paginated' => true, 'native_filter' => true]; +$filter = ['search' => 'generated item & related', 'published' => 1, 'enabled' => false]; + +foreach ([false, true] as $strict) +{ + $arguments = $schemas->input(['offset' => 40, 'limit' => 20, 'filter' => $strict ? (object) $filter : $filter], $listSchema, $strict); + $request = $builder->build($arguments, $listRoute); + $check($request['query'] === ['page[offset]' => 40, 'page[limit]' => 20, + 'filter[search]' => $filter['search'], 'filter[published]' => 1, 'filter[enabled]' => 0], 'Nested native filters were not transferred intact.'); +} +$request = $builder->build(['filter' => new stdClass()], $listRoute); +$check($request['query'] === ['page[offset]' => 0, 'page[limit]' => 20], 'An empty wire filter object was not accepted.'); + +foreach ([['search'], (object) ['search' => ['nested']], (object) ['search' => new stdClass()], + (object) ['search' => null], (object) ['bad[name]' => 'x'], (object) ['search' => str_repeat('x', 2049)], + array_fill_keys(array_map(static fn (int $index): string => 'field' . $index, range(1, 33)), 'x')] as $invalid) +{ + $reject(static fn () => $builder->build(['filter' => $invalid], $listRoute)); +} +$reject(static fn () => $schemas->input(['filter' => ['search']], $listSchema, true)); +$reject(static fn () => $schemas->input(['filter' => (object) ['search' => (object) ['nested' => 'x']]], $listSchema, true)); + +// Generated cleanFilter() accepts scalar lists for native multi-select state. +// That capability is explicit; existing Joomla scalar-filter bindings retain +// their original contract. Empty arrays have no lossless HTTP query encoding. +$multiRoute = array_replace($listRoute, ['native_filter_arrays' => true]); +$multiFilter = ['access' => [1, 3], 'published' => [0, 1], 'selected' => [true, false], 'search' => 'native multiselect']; +$request = $builder->build(['filter' => (object) $multiFilter], $multiRoute); +$check($request['query'] === ['page[offset]' => 0, 'page[limit]' => 20, 'filter[access][0]' => 1, 'filter[access][1]' => 3, + 'filter[published][0]' => 0, 'filter[published][1]' => 1, 'filter[selected][0]' => 1, 'filter[selected][1]' => 0, + 'filter[search]' => 'native multiselect'], 'Native filter lists changed their order, scalar values or query structure.'); +$request = $builder->build(['filter' => ['access' => range(1, 64)]], $multiRoute); +$check(count($request['query']) === 66 && $request['query']['filter[access][63]'] === 64, 'The declared native filter-list bound changed.'); + +foreach ([[], range(1, 65), ['named' => 1], [[1]], [new stdClass()], [null], + [str_repeat('x', 2049)], [INF], [NAN]] as $invalid) +{ + $reject(static fn () => $builder->build(['filter' => ['access' => $invalid]], $multiRoute)); +} +$reject(static fn () => $builder->build(['filter' => ['access' => [1, 3]]], $listRoute)); +$reject(static fn () => $builder->build(['filter' => ['access' => [1, 3]]], array_replace($multiRoute, ['native_filter_arrays' => 'true']))); + +// Exercise the same declarations emitted from reviewed native route rules, +// rather than only constructing the transport configurations by hand. +$routes = [ + ['method' => 'GET', 'route' => $guidRoute['route'], 'controller' => 'items.displayItem', 'variables' => ['guid'], + 'rules' => ['guid' => '([0-9a-fA-F-]{36})'], 'defaults' => ['component' => 'com_componentbuilder']], + ['method' => 'GET', 'route' => $keyRoute['route'], 'controller' => 'items.displayItem', 'variables' => ['key'], + 'rules' => ['key' => '([^/]+)'], 'defaults' => ['component' => 'com_componentbuilder']], + ['method' => 'GET', 'route' => $listRoute['route'], 'controller' => 'items.displayList', 'variables' => [], + 'rules' => [], 'defaults' => ['component' => 'com_componentbuilder']], +]; +$graph = (new CatalogueBuilder())->build(['commands' => [], 'unsupported' => []], ['routes' => $routes, 'unsupported' => []]); +$documents = array_column($graph['entities']['schema'], 'document', 'id'); + +foreach ($graph['entities']['binding'] as $registered) +{ + $configuration = Json::decode($registered['configuration']); + $arguments = match ($configuration['route']) { + $guidRoute['route'] => ['guid' => $guid], + $keyRoute['route'] => ['key' => 'alpha..beta value'], + default => ['filter' => (object) ($filter + ['access' => [1, 3]])], + }; + $validated = $schemas->input($arguments, $documents[$registered['input_schema_id']], true); + $request = $builder->build($validated, $configuration); + $check($request['path'] === match ($configuration['route']) { + $guidRoute['route'] => '/v1/generated/items/guid/' . $guid, + $keyRoute['route'] => '/v1/generated/items/key/alpha..beta%20value', + default => $listRoute['route'], + }, 'A catalogue-generated identifier or nested filter declaration did not match its transport.'); + + if ($configuration['route'] === $listRoute['route']) + { + $check(($configuration['native_filter_arrays'] ?? false) === true + && $request['query']['filter[access][0]'] === 1 && $request['query']['filter[access][1]'] === 3, + 'A generated catalogue list binding did not preserve native multi-select filters.'); + } + + if ($configuration['route'] === $keyRoute['route']) + { + foreach (['.', '..', '%2fother', 'other/child', 'other\\child'] as $invalid) + { + $reject(static fn () => $schemas->input(['key' => $invalid], $documents[$registered['input_schema_id']], true)); + } + } +} + +$writeRoute = array_replace($guidRoute, ['method' => 'PATCH', 'operation' => 'update', 'body_policy' => 'required', + 'preserve_fields' => ['guid'], 'body_defaults' => ['published' => 1]]); +$data = (object) ['name' => 'Changed name', 'settings' => (object) ['enabled' => true], + 'subform' => [(object) ['name' => 'child', 'empty' => new stdClass()]], 'code' => 'build(['guid' => $guid, 'data' => $data], $writeRoute, ['guid' => $guid]); +$check($request['body']['guid'] === $guid && $request['body']['published'] === 1, 'Wire body normalization lost preservation or native defaults.'); +$check($request['body']['settings'] instanceof stdClass && $request['body']['subform'][0]->empty instanceof stdClass, + 'Wire body normalization changed nested object/list shapes.'); +$check(!property_exists($data, 'guid') && !property_exists($data, 'published'), 'Building a request mutated the caller\'s body object.'); +$reject(static fn () => $builder->build(['guid' => $guid, 'data' => new stdClass()], $writeRoute)); +$reject(static fn () => $builder->build(['guid' => $guid, 'data' => ['list']], $writeRoute)); + +/** Recording transport double verifies actual URI/body bytes, not persistence. */ +$http = new class implements ClientInterface +{ + /** @var ?RequestInterface Last emitted request. */ + public ?RequestInterface $request = null; + + /** @inheritDoc */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->request = $request; + return new Response(200, ['Content-Type' => 'application/vnd.api+json'], '{"data":[]}'); + } +}; +$principal = new Principal('joomla:17', 'api', [1]); +$handler = new ApiHandler($http, $builder, new Settings(['api_base' => 'https://joomla.example/api/index.php']), 'fixture-token', static fn (): string => 'unused'); +$binding = ['handler' => 'api.request', 'track' => 'api', 'configuration' => $listRoute]; +$handler->execute(['offset' => 40, 'filter' => (object) $filter], $binding, $principal); +parse_str($http->request->getUri()->getQuery(), $query); +$check($query === ['page' => ['offset' => '40', 'limit' => '20'], + 'filter' => ['search' => $filter['search'], 'published' => '1', 'enabled' => '0']], 'HTTP query encoding did not reach Joomla\'s native nested parameter shape.'); +$handler->execute(['filter' => (object) $multiFilter], array_replace($binding, ['configuration' => $multiRoute]), $principal); +parse_str($http->request->getUri()->getQuery(), $query); +$check($query === ['page' => ['offset' => '0', 'limit' => '20'], 'filter' => ['access' => ['1', '3'], 'published' => ['0', '1'], + 'selected' => ['1', '0'], 'search' => 'native multiselect']], 'HTTP query encoding did not preserve native scalar-list filter values.'); +$handler->request(['guid' => $guid, 'data' => $data], array_replace($binding, ['configuration' => $writeRoute]), $principal, null, ['guid' => $guid]); +$sent = Json::decode((string) $http->request->getBody(), false); +$check($http->request->getUri()->getPath() === '/api/index.php/v1/generated/items/guid/' . $guid + && $sent->guid === $guid && $sent->settings instanceof stdClass + && is_array($sent->subform) && $sent->subform[0]->empty instanceof stdClass && $sent->code === $data->code, + 'HTTP mutation altered its GUID, nested subforms, or inert source text.'); + +// Existing Joomla route primitives remain distinct and continue to resolve. +foreach ([['positive-integer', 42, '42'], ['component-name', 'com_content', 'com_content'], + ['language-code', 'en-GB', 'en-GB'], ['override-constant', 'COM_CONTENT_TITLE', 'COM_CONTENT_TITLE'], + ['adapter-id', 'local-images', 'local-images'], ['media-path', 'images/folder/image one.jpg', 'images/folder/image%20one.jpg']] as [$kind, $value, $encoded]) +{ + $route = ['method' => 'GET', 'route' => '/v1/core/items/:value', 'route_parameters' => [['name' => 'value', 'kind' => $kind]]]; + $check($builder->build(['value' => $value], $route)['path'] === '/v1/core/items/' . $encoded, 'A Joomla core route primitive regressed: ' . $kind); +} +$reject(static fn () => $builder->build(['value' => '42'], ['method' => 'GET', 'route' => '/v1/core/items/:value', + 'route_parameters' => [['name' => 'value', 'kind' => 'positive-integer']]])); +$reject(static fn () => $builder->build(['value' => 'images/../private'], ['method' => 'GET', 'route' => '/v1/core/items/:value', + 'route_parameters' => [['name' => 'value', 'kind' => 'media-path']]])); + +echo Json::encode(['checks' => $checks, 'generatedApiTransport' => 'passed', 'liveJoomla' => 'not run']) . PHP_EOL; diff --git a/tests/golden-image/full-api-catalogue.php b/tests/golden-image/full-api-catalogue.php new file mode 100644 index 0000000..084d42c --- /dev/null +++ b/tests/golden-image/full-api-catalogue.php @@ -0,0 +1,263 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Database\DatabaseInterface; +use VDM\Component\JoomEngineMcp\Administrator\Database\JoomlaStore; +use VDM\Component\JoomEngineMcp\Administrator\Database\Structure; +use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\CatalogueBuilder; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\InventoryTransport; +use VDM\Component\JoomEngineMcp\Administrator\Process\PhpProcess; +use VDM\Component\JoomEngineMcp\Administrator\Security\LocalPrincipal; +use VDM\Component\JoomEngineMcp\Administrator\Security\SchemaValidator; +use VDM\Component\JoomEngineMcp\Administrator\Service\Json; + +require dirname(__DIR__) . '/integration/bootstrap.php'; +require dirname(__DIR__) . '/integration/HttpFixture.php'; +$factory = $app->bootComponent('com_joomengine_mcp')->getMVCFactory(); +$db = $container->get(DatabaseInterface::class); +$store = new JoomlaStore($db); +$admin = $container->get(UserFactoryInterface::class)->loadUserByUsername('mcp_test_admin'); +$app->loadIdentity($admin); +$phase = $argv[1] ?? ''; +$started = hrtime(true); +$stateFile = '/tmp/mcp-full-api-catalogue.json'; +$checks = 0; +$check = static function (bool $condition, string $label) use (&$checks): void +{ + if (!$condition) + { + throw new RuntimeException($label); + } + $checks++; + echo 'PASS ' . $label . PHP_EOL; +}; +$hash = static fn (mixed $value): string => Json::canonicalHash($value); +$core = static function () use ($store, $hash): array +{ + $provider = $store->one('provider', ['name' => 'joomla.core']); + if ($provider === null) + { + throw new RuntimeException('The native core catalogue provider is missing.'); + } + $result = []; + foreach (array_keys(Structure::definitions()) as $entity) + { + $records = $entity === 'provider' ? [$provider] : $store->find($entity, ['provider_id' => (int) $provider['id']], 10000); + foreach ($records as $record) + { + $result[$entity . ':' . $record['id']] = $hash($record); + } + } + ksort($result, SORT_STRING); + return $result; +}; +$saveState = static function (array $state) use ($stateFile): void +{ + $oldMask = umask(0077); + try + { + if (file_put_contents($stateFile, Json::encode($state), LOCK_EX) === false) + { + throw new RuntimeException('The private full API fixture manifest could not be saved.'); + } + } + finally + { + umask($oldMask); + } +}; + +if ($phase === 'prepare') +{ + $check(!is_file($stateFile), 'The full API acceptance starts without a stale fixture manifest'); + $pluginId = (int) $db->setQuery($db->createQuery()->select($db->quoteName('extension_id')) + ->from($db->quoteName('#__extensions'))->where($db->quoteName('type') . ' = ' . $db->quote('plugin')) + ->where($db->quoteName('folder') . ' = ' . $db->quote('webservices')) + ->where($db->quoteName('element') . ' = ' . $db->quote('mcpfulljcbapi')))->loadResult(); + $check($pluginId > 0 && is_file(JPATH_PLUGINS . '/webservices/mcpfulljcbapi/native-routing-provenance.json'), + 'Native Joomla installation owns the separate disposable compiler-rendered routing plugin'); + $pluginModel = $app->bootComponent('com_plugins')->getMVCFactory()->createModel('Plugin', 'Administrator', ['ignore_request' => true]); + $pluginModel->setCurrentUser($admin); + $check($pluginModel->save(['extension_id' => $pluginId, 'enabled' => 1]), + 'Native plugin administration explicitly enables only the owned full API routing fixture'); + $archive = new ZipArchive(); + $check($archive->open('/tmp/mcp-full-api-componentbuilder.zip') === true + && hash_file('sha256', '/tmp/mcp-full-api-componentbuilder.zip') === 'a5870a1b162c9db1f8c23b87562389c46260838f471914132ef1d3d66fb8f55a', + 'The installed full component archive matches the exact supplied package'); + $controllers = 0; + $installedSources = 0; + for ($index = 0; $index < $archive->numFiles; $index++) + { + $name = $archive->getNameIndex($index); + if (!is_string($name) || str_ends_with($name, '/')) + { + continue; + } + $relative = null; + if (str_starts_with($name, 'api/src/')) + { + $relative = JPATH_ROOT . '/api/components/com_componentbuilder/' . substr($name, 4); + $controllers += str_starts_with($name, 'api/src/Controller/') && str_ends_with($name, 'Controller.php') ? 1 : 0; + } + elseif (str_starts_with($name, 'admin/forms/') || str_starts_with($name, 'admin/src/Model/')) + { + $relative = JPATH_ADMINISTRATOR . '/components/com_componentbuilder/' . substr($name, 6); + } + if ($relative !== null) + { + $source = $archive->getFromIndex($index); + $check(is_string($source) && is_file($relative) && hash_equals(hash('sha256', $source), hash_file('sha256', $relative)), + 'Native installer retains the supplied contract source ' . $name); + $installedSources++; + } + } + $archive->close(); + $check($controllers === 104, 'The supplied 102 CRUD and two read-only API controllers are actually installed'); + $principal = new LocalPrincipal($app); + $packed = (new PhpProcess(PHP_BINARY, JPATH_COMPONENT . '/cli/jcb.php', JPATH_ROOT))->run([ + 'protocol' => 'joomengine-worker/1', 'operation' => 'jcb.inventory', 'inventory_format' => InventoryTransport::FORMAT, + 'authority' => ['id' => $principal->getId(), 'track' => 'cli'], + ], 90, InventoryTransport::MAX_WIRE_BYTES, static fn (): bool => false); + $check(!isset($packed['error']) && ($packed['inventory_format'] ?? '') === InventoryTransport::FORMAT, + 'The actual isolated inventory worker returns its opted-in compact full API contract'); + $compactBytes = strlen(Json::encode($packed)); + $inventory = InventoryTransport::unpack($packed); + $expandedBytes = strlen(json_encode($inventory, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION)); + $check($expandedBytes > 8388608 && $compactBytes < 8388608, + 'Actual native commands and full form-backed API exceed the old 8 MiB envelope while compact IPC stays bounded'); + $legacyFailure = null; + try + { + (new PhpProcess(PHP_BINARY, JPATH_COMPONENT . '/cli/jcb.php', JPATH_ROOT))->run([ + 'protocol' => 'joomengine-worker/1', 'operation' => 'jcb.inventory', + 'authority' => ['id' => $principal->getId(), 'track' => 'cli'], + ], 90, 8388608, static fn (): bool => false); + } + catch (OperationException $error) + { + $legacyFailure = $error->getIdentifier(); + } + $check(in_array($legacyFailure, ['WORKER_OUTPUT_LIMIT', 'WORKER_FAILED'], true), + 'The same installed full API worker demonstrably exceeds the unchanged legacy IPC limit without compact opt-in'); + $routes = array_values(array_filter($inventory['api']['routes'], static fn (array $route): bool => + ($route['defaults']['component'] ?? '') === 'com_componentbuilder')); + $forms = []; + $contractRoutes = 0; + $guidRoutes = 0; + foreach ($routes as $route) + { + $check(in_array('webservices/mcpfulljcbapi', $route['required_extensions'] ?? [], true), + 'Actual native registration records the compiler-rendered fixture plugin owner'); + if (isset($route['form_contract'])) + { + $forms[$route['form_contract']['provenance']['form']] = true; + $contractRoutes++; + } + $guidRoutes += in_array('guid', $route['variables'], true) ? 1 : 0; + } + $check(count($routes) === 320 && $contractRoutes === 318 && count($forms) === 51 && $guidRoutes === 63, + 'Every supplied resource family and GUID registration reaches the actual worker with its complete native form'); + $seed = (new CatalogueBuilder())->build($inventory['commands'], $inventory['api']); + $expected = []; + foreach (['schema' => ['document'], 'action' => ['definition'], 'binding' => ['configuration', 'definition', 'params']] as $entity => $fields) + { + foreach ($seed['entities'][$entity] as $record) + { + foreach ($fields as $field) + { + $expected[$entity][$record['name']][$field] = hash('sha256', $record[$field]); + } + } + } + $state = ['revision' => $seed['source'], 'core' => $core(), 'expected' => $expected, + 'expandedBytes' => $expandedBytes, 'compactBytes' => $compactBytes, 'registeredRoutes' => count($routes), + 'forms' => count($forms), 'contractRoutes' => $contractRoutes, 'guidRoutes' => $guidRoutes, + 'nativeCommands' => count($inventory['commands']['commands']), 'installedSources' => $installedSources, + 'legacyWorkerFailure' => $legacyFailure]; + $saveState($state); +} +elseif (in_array($phase, ['verify', 'verify-rerun'], true)) +{ + $state = Json::decode(file_get_contents($stateFile)); + $check($core() === $state['core'], 'Full API synchronization preserves every existing Joomla core definition byte for byte'); + $provider = $store->one('provider', ['name' => 'jcb.installed']); + $definition = $provider === null ? [] : Json::decode($provider['definition']); + $check(($definition['routeCount'] ?? 0) === 320 && ($definition['inventory'] ?? '') === $state['revision'], + 'The real joomla:mcp:jcb-sync command persists the complete full-size native inventory revision'); + $validator = new SchemaValidator(); + foreach ($state['expected'] as $entity => $records) + { + foreach ($records as $name => $fields) + { + $record = $store->one($entity, ['name' => $name]); + $check($record !== null && (int) $record['published'] === 1, 'The full native graph publishes ' . $entity . ' ' . $name); + foreach ($fields as $field => $fingerprint) + { + $check(hash_equals($fingerprint, hash('sha256', $record[$field])), + 'No schema, form, provenance or verification data is truncated: ' . $name . ' ' . $field); + } + if ($entity === 'schema') + { + $check(is_object($validator->document($record['document'])), 'Native full-package schema parses through the production validator: ' . $name); + } + } + } + $client = new HttpFixture((string) getenv('MCP_TEST_BASE_URL'), trim(file_get_contents((string) getenv('MCP_TEST_TOKEN_FILE')))); + try + { + $client->initialize(); + $tools = array_column($client->rpc('tools/list')['result']['tools'] ?? [], 'name'); + foreach (['joomla_actions_search', 'joomla_action_describe', 'joomla_action_read', 'joomla_action_write_plan', 'joomla_write_apply'] as $tool) + { + $check(in_array($tool, $tools, true), 'The actual authenticated MCP endpoint retains generic API tool ' . $tool); + } + $search = $client->tool('joomla_actions_search', ['text' => 'jcb.api.', 'domain' => 'jcb', 'includeWrites' => true]); + $names = array_column($search['actions'] ?? [], 'id'); + $expectedNames = array_values(array_filter(array_keys($state['expected']['action']), static fn (string $name): bool => str_starts_with($name, 'jcb.api.'))); + sort($names, SORT_STRING); + sort($expectedNames, SORT_STRING); + $check($names === $expectedNames && count($names) === 320, 'All full native API registrations are discoverable through the authenticated MCP tools'); + $coreRead = $client->tool('joomla_action_read', ['action' => 'contacts.contacts.list', 'transport' => 'api', 'input' => ['offset' => 0, 'limit' => 1]]); + $check(($coreRead['response']['status'] ?? null) === 200, 'Existing Joomla core API forwarding still works after both full-size synchronizations'); + } + finally + { + $client->disconnect(); + } + if ($phase === 'verify') + { + $action = $store->one('action', ['name' => $expectedNames[0]]); + $editor = $factory->createModel('Action', 'Administrator', ['ignore_request' => true]); + $editor->setCurrentUser($admin); + $check($editor->save(['id' => (int) $action['id'], 'version' => (int) $action['version'], 'title' => 'Owned full API regression customization']), + 'Native administration customizes a freshly synchronized API action before rerunning the real command'); + $state['customization'] = $store->one('action', ['id' => (int) $action['id']]); + $saveState($state); + } + else + { + $check($store->one('action', ['id' => (int) $state['customization']['id']]) === $state['customization'], + 'Repeated full native synchronization preserves the exact operator customization and revision'); + $check(unlink($stateFile), 'Remove the private full API fixture manifest; native component uninstall owns the disposable graph cleanup'); + } +} +else +{ + throw new RuntimeException('Use prepare, verify or verify-rerun in the disposable golden fixture.'); +} + +echo Json::encode(['checks' => $checks, 'phase' => $phase, 'registeredRoutes' => $state['registeredRoutes'], + 'nativeForms' => $state['forms'], 'formBackedRoutes' => $state['contractRoutes'], 'guidRoutes' => $state['guidRoutes'], + 'nativeCommands' => $state['nativeCommands'], 'expandedInventoryBytes' => $state['expandedBytes'], + 'compactInventoryBytes' => $state['compactBytes'], 'suppliedPackageSha256' => 'a5870a1b162c9db1f8c23b87562389c46260838f471914132ef1d3d66fb8f55a', + 'legacyWorkerFailure' => $state['legacyWorkerFailure'], 'elapsedSeconds' => round((hrtime(true) - $started) / 1000000000, 3), + 'fixturePeakMemoryBytes' => memory_get_peak_usage(true), + 'routingFixture' => 'Native JCB compiler-rendered registration; historical linked plugin was not supplied.', 'joomla' => JVERSION]) . PHP_EOL; diff --git a/tests/golden-image/full-api-plugin.php b/tests/golden-image/full-api-plugin.php new file mode 100644 index 0000000..e20af65 --- /dev/null +++ b/tests/golden-image/full-api-plugin.php @@ -0,0 +1,349 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + * + * Build a disposable routing fixture with JCB's installed native compiler. + * The supplied full API package has no historical linked routing plugin. + * Its controllers supply the resource names, roles and record-key projection; + * the native Resources, RecordId and Routes services supply all route code. + */ + +use Joomla\CMS\Event\Application\BeforeApiRouteEvent; +use Joomla\CMS\Router\ApiRouter; +use VDM\Joomla\Componentbuilder\Compiler\Factory as CompilerFactory; +use VDM\Plugin\Webservices\Mcpfulljcbapi\Extension\Mcpfulljcbapi; + +require dirname(__DIR__) . '/integration/bootstrap.php'; +$app->bootComponent('com_componentbuilder'); + +$authorityFile = dirname(__DIR__) . '/fixtures/jcb/native-routing-provenance.json'; +$authority = json_decode(file_get_contents($authorityFile), true, 64, JSON_THROW_ON_ERROR); +$controllerRoot = JPATH_ROOT . '/api/components/com_componentbuilder/src/Controller'; +$files = glob($controllerRoot . '/*Controller.php'); +sort($files, SORT_STRING); +$require = static function (bool $ok, string $message): void +{ + if (!$ok) + { + throw new RuntimeException($message); + } +}; +$require(count($files) === $authority['controllers'], 'The supplied full API must contain exactly 104 controllers.'); + +/** Extract one method without evaluating supplied controller code. */ +$method = static function (string $source, string $name): ?string +{ + $tokens = token_get_all($source); + $count = count($tokens); + + for ($index = 0; $index < $count; $index++) + { + if (!is_array($tokens[$index]) || $tokens[$index][0] !== T_FUNCTION) + { + continue; + } + + $next = $index + 1; + + while ($next < $count && is_array($tokens[$next]) && $tokens[$next][0] === T_WHITESPACE) + { + $next++; + } + + if (!is_array($tokens[$next]) || $tokens[$next][0] !== T_STRING || $tokens[$next][1] !== $name) + { + continue; + } + + while ($next < $count && $tokens[$next] !== '{') + { + $next++; + } + + $body = ''; + $depth = 0; + + for (; $next < $count; $next++) + { + $token = $tokens[$next]; + $body .= is_array($token) ? $token[1] : $token; + $depth += $token === '{' ? 1 : ($token === '}' ? -1 : 0); + + if ($depth === 0) + { + return $body; + } + } + } + + return null; +}; + +$controllers = []; +$digest = ''; + +foreach ($files as $file) +{ + $source = file_get_contents($file); + $path = 'api/src/Controller/' . basename($file); + $digest .= $path . "\0" . hash('sha256', $source) . "\n"; + $properties = []; + + foreach (['contentType', 'default_view'] as $property) + { + $pattern = '/protected\s+\$' . $property . "\s*=\s*'([a-z][a-z0-9_]*)'\s*;/"; + $require(preg_match_all($pattern, $source, $matches) === 1, $path . ' must have a literal ' . $property . '.'); + $properties[$property] = $matches[1][0]; + } + + $controllers[] = $properties + ['file' => $path, 'source' => $source, + 'record' => $method($source, 'getRecordId')]; +} + +$require(hash_equals($authority['controller_sha256'], hash('sha256', $digest)), + 'The installed API controller bytes must match the supplied package provenance.'); +$pairs = []; +$guidItems = 0; + +foreach ($controllers as $controller) +{ + if ($controller['record'] === null) + { + continue; + } + + $list = $controller['contentType']; + $single = $controller['default_view']; + $require(!isset($pairs[$list]) && $single !== $list, 'Every item controller must have one distinct list resource.'); + $keys = []; + + if (preg_match('/foreach\s*\(\s*\[([^\]]*)\]\s+as\s+\$key\s*\)/', $controller['record'], $match) === 1) + { + $require(trim($match[1]) === "'guid'", 'The supplied item key projection must be GUID or primary ID only.'); + $keys = ['guid']; + $guidItems++; + } + + $pairs[$list] = ['single' => $single, 'list' => $list, 'keys' => $keys, 'item_controller' => $controller['file']]; +} + +$require(count($pairs) === $authority['administrator_pairs'] && $guidItems === $authority['guid_items'], + 'The supplied API must contain exactly 51 administrator pairs and 21 GUID item resources.'); +$readonly = []; + +foreach ($controllers as $controller) +{ + if ($controller['record'] !== null) + { + continue; + } + + $name = $controller['contentType']; + $require($controller['default_view'] === $name, 'A list controller must use its literal content type as its view.'); + $require($method($controller['source'], 'displayList') !== null, 'Every projected list must expose displayList.'); + + if (isset($pairs[$name])) + { + $require(!isset($pairs[$name]['list_controller']), 'Each item must have exactly one list controller.'); + $pairs[$name]['list_controller'] = $controller['file']; + continue; + } + + $require(in_array($name, $authority['readonly_lists'], true) && !isset($readonly[$name]), + 'The only unpaired controllers must be the supplied compiler and extrusion lists.'); + $model = $method($controller['source'], 'getModel'); + $require(is_string($model) && preg_match("/parent::getModel\('" . ucfirst($name) . "',\s*'Administrator'/", $model) === 1, + 'The dynamic list must bind its actual administrator model.'); + + foreach (['displayItem', 'add', 'edit', 'delete'] as $task) + { + $body = $method($controller['source'], $task); + $require(is_string($body) && str_contains($body, 'throw new') && str_contains($body, ', 405)'), + 'The dynamic controller must reject every item and write task.'); + } + + $readonly[$name] = ['code' => $name, 'controller' => $controller['file']]; +} + +ksort($pairs, SORT_STRING); +ksort($readonly, SORT_STRING); +$require(array_keys($readonly) === $authority['readonly_lists'], 'Both supplied read-only resources must be present.'); +$adminLinks = []; + +foreach ($pairs as $pair) +{ + $require(isset($pair['list_controller']), 'Every administrator item must have its supplied list controller.'); + $fields = $pair['keys'] === [] ? [] : [['settings' => (object) [ + 'xml' => '', 'name' => 'guid', + 'type_name' => 'text', 'datatype' => 'CHAR', 'indexes' => 1]]]; + $adminLinks[] = ['add_api' => 2, 'settings' => (object) [ + 'name_single_code' => $pair['single'], 'name_list_code' => $pair['list'], + 'name_single' => $pair['single'], 'name_list' => $pair['list'], 'fields' => $fields]]; +} + +$dynamicLinks = []; + +foreach ($readonly as $resource) +{ + $dynamicLinks[] = ['access' => 1, 'settings' => (object) [ + 'code' => $resource['code'], 'main_get' => (object) ['gettype' => 2, 'main_source' => 1]]]; +} + +$temporary = null; +$zip = null; + +try +{ + // Resolve only the renderer's native collaborators, never the full Compiler. + $config = CompilerFactory::_('Config'); + $config->set('component_code_name', 'componentbuilder'); + $config->set('joomla_version', 6); + $config->set('indentation_value', "\t"); + $config->set('debug_line_nr', false); + $renderer = CompilerFactory::_('Architecture.Api.Plugin.Routes'); + $rendererFile = (new ReflectionClass($renderer))->getFileName(); + $require(realpath($rendererFile) === realpath(JPATH_ROOT . '/' . $authority['renderer']), + 'The renderer must be the installed native JCB Routes service.'); + $routeMethod = $renderer->getMethod($adminLinks, $dynamicLinks); + $provenance = $authority + ['plugin' => 'plg_webservices_mcpfulljcbapi', + 'renderer_sha256' => hash_file('sha256', $rendererFile), + 'rendered_method_sha256' => hash('sha256', $routeMethod), + 'projected_pairs' => array_values($pairs), 'projected_readonly' => array_values($readonly)]; + $provenanceJson = json_encode($provenance, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . "\n"; + $plugin = <<<'PHP' + 'onBeforeApiRoute']; + } + +PHP; + $plugin .= "\t" . $routeMethod . "\n}\n"; + $provider = <<<'PHP' +set(PluginInterface::class, $container->lazy(Mcpfulljcbapi::class, function (Container $container) + { + $plugin = new Mcpfulljcbapi((array) PluginHelper::getPlugin('webservices', 'mcpfulljcbapi')); + $plugin->setApplication(Factory::getApplication()); + + return $plugin; + })); + } +}; +PHP; + $manifest = <<<'XML' + + + plg_webservices_mcpfulljcbapi + MCP disposable acceptance fixture + 1.0.0 + Compiler-rendered routing fixture for the supplied JCB full API. Source authority and projection are recorded in native-routing-provenance.json; no historical linked routing plugin was supplied. + VDM\Plugin\Webservices\Mcpfulljcbapi + + services + src + native-routing-provenance.json + + +XML; + $temporary = tempnam(sys_get_temp_dir(), 'mcp-full-api-'); + $require(is_string($temporary) && file_put_contents($temporary, $plugin) === strlen($plugin), + 'Unable to write the temporary native plugin proof.'); + require $temporary; + $router = new ApiRouter($app); + $nativePlugin = new Mcpfulljcbapi(['name' => 'mcpfulljcbapi', 'type' => 'webservices']); + $nativePlugin->setApplication($app); + $nativePlugin->onBeforeApiRoute(new BeforeApiRouteEvent('onBeforeApiRoute', ['subject' => $app, 'router' => $router])); + $routes = []; + $methods = []; + + foreach ($router->getRoutes() as $route) + { + $defaults = $route->getDefaults(); + $require(($defaults['component'] ?? '') === 'com_componentbuilder', 'Every native route must retain JCB ownership.'); + + foreach ($route->getMethods() as $verb) + { + $key = $verb . ' ' . $route->getPattern(); + $require(!isset($routes[$key]), 'The native renderer must register unique method/path pairs.'); + $require($verb !== 'GET' || ($defaults['public'] ?? null) === false, 'All supplied API reads require native authentication.'); + $routes[$key] = ['method' => $verb, 'route' => $route->getPattern(), 'controller' => $route->getController(), + 'rules' => $route->getRules(), 'defaults' => $defaults]; + $methods[$verb] = ($methods[$verb] ?? 0) + 1; + } + } + + ksort($routes, SORT_STRING); + ksort($methods, SORT_STRING); + $expectedMethods = $authority['native_methods']; + ksort($expectedMethods, SORT_STRING); + $require(count($routes) === $authority['native_routes'] && $methods === $expectedMethods, + 'The real native plugin event must register exactly 320 routes with the supplied method counts.'); + $archive = '/tmp/mcp-full-api-routing.zip'; + $zip = new ZipArchive(); + $require($zip->open($archive, ZipArchive::CREATE | ZipArchive::OVERWRITE) === true, 'Unable to create the routing fixture ZIP.'); + $entries = ['mcpfulljcbapi.xml' => $manifest . "\n", 'native-routing-provenance.json' => $provenanceJson, + 'services/provider.php' => $provider . "\n", 'src/Extension/Mcpfulljcbapi.php' => $plugin]; + ksort($entries, SORT_STRING); + + foreach ($entries as $path => $content) + { + $require($zip->addFromString($path, $content) && $zip->setMtimeName($path, 1788307200), + 'Unable to add a deterministic routing fixture entry.'); + } + + $require($zip->close(), 'Unable to finish the routing fixture ZIP.'); + $zip = null; + echo json_encode(['fixture' => $authority['fixture'], 'archive' => $archive, + 'archive_sha256' => hash_file('sha256', $archive), 'plugin' => 'plg_webservices_mcpfulljcbapi', + 'plugin_element' => 'mcpfulljcbapi', 'plugin_group' => 'webservices', + 'required_extensions' => ['webservices/mcpfulljcbapi'], 'controllers' => count($controllers), + 'administrator_pairs' => count($pairs), 'guid_items' => $guidItems, 'readonly_lists' => array_keys($readonly), + 'native_routes' => count($routes), 'native_methods' => $methods, 'provenance' => $provenance, + 'routes' => array_values($routes)], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR) . PHP_EOL; +} +finally +{ + if ($zip !== null) + { + $zip->close(); + } + + if (is_string($temporary) && is_file($temporary)) + { + unlink($temporary); + } + + CompilerFactory::unset(); +} diff --git a/tests/golden-image/run.sh b/tests/golden-image/run.sh index d8124c2..196d2bf 100755 --- a/tests/golden-image/run.sh +++ b/tests/golden-image/run.sh @@ -80,7 +80,7 @@ fixture() { } fixture /tmp/mcp-component/tests/golden-image/prepare.php > "$out/prepare.log" 2>&1 fixture /tmp/mcp-component/tests/integration/prepare-http.php >> "$out/prepare.log" 2>&1 -for suite in installation administration admin-pagination http field-defaults write-verification message-snapshot template-styles menu-components native-lists content-languages catalogue-mcp acl-mcp stdio stdio-endurance browser browser-pagination catalogue-page-limits jcb-api job-worker; do +for suite in installation administration admin-pagination http field-defaults write-verification message-snapshot template-styles menu-components native-lists content-languages catalogue-mcp acl-mcp stdio stdio-endurance browser browser-pagination catalogue-page-limits jcb-api generated-api job-worker; do fixture "/tmp/mcp-component/tests/integration/$suite.php" > "$out/$suite.log" 2>&1 done fixture /tmp/mcp-plugin/tests/installed.php > "$out/console-plugin.log" 2>&1 @@ -131,11 +131,32 @@ if [[ -n "${MCP_CLIENT_SOURCE:-}" ]]; then fi fixture /tmp/mcp-component/tests/golden-image/registry.php > "$out/jcb-command-registry.json" 2> "$out/registry-errors.log" php -r '$v=json_decode(file_get_contents($argv[1]),true,512,JSON_THROW_ON_ERROR); $c=array_filter($v["commands"]??[],static fn($c)=>str_starts_with($c["name"],"componentbuilder:")); if(count($c)<2)throw new RuntimeException("The installed JCB command registry is empty."); echo "Verified ",count($c)," native JCB command definitions\n";' "$out/jcb-command-registry.json" > "$out/registry.log" +# The pinned source distribution above has no full JCB API. Install the exact +# supplied package only after those compiler/package scenarios have completed. +cat tests/fixtures/jcb/full-api-componentbuilder-6.1.6.zip.001 \ + tests/fixtures/jcb/full-api-componentbuilder-6.1.6.zip.002 > build/full-api-componentbuilder-6.1.6.zip +printf '%s %s\n' 'a5870a1b162c9db1f8c23b87562389c46260838f471914132ef1d3d66fb8f55a' \ + build/full-api-componentbuilder-6.1.6.zip | sha256sum --check > "$out/full-api-archive.log" +compose cp build/full-api-componentbuilder-6.1.6.zip joomla:/tmp/mcp-full-api-componentbuilder.zip +compose exec -T joomla php /var/www/html/cli/joomla.php extension:install \ + --path=/tmp/mcp-full-api-componentbuilder.zip --no-interaction --no-ansi > "$out/full-api-install.log" 2>&1 +# The supplied component ZIP contains no linked routing plugin. This separate +# disposable plugin uses the installed package's native JCB Routes renderer. +fixture /tmp/mcp-component/tests/golden-image/full-api-plugin.php > "$out/full-api-routing-provenance.json" 2> "$out/full-api-routing-errors.log" +compose exec -T joomla php /var/www/html/cli/joomla.php extension:install \ + --path=/tmp/mcp-full-api-routing.zip --no-interaction --no-ansi > "$out/full-api-routing-install.log" 2>&1 +fixture /tmp/mcp-component/tests/golden-image/full-api-catalogue.php prepare > "$out/full-api-prepare.log" 2>&1 +compose exec -T joomla php /var/www/html/cli/joomla.php joomla:mcp:jcb-sync \ + --no-interaction --no-ansi > "$out/full-api-sync.json" 2> "$out/full-api-sync-errors.log" +fixture /tmp/mcp-component/tests/golden-image/full-api-catalogue.php verify > "$out/full-api-verify.log" 2>&1 +compose exec -T joomla php /var/www/html/cli/joomla.php joomla:mcp:jcb-sync \ + --no-interaction --no-ansi > "$out/full-api-sync-rerun.json" 2> "$out/full-api-sync-rerun-errors.log" +fixture /tmp/mcp-component/tests/golden-image/full-api-catalogue.php verify-rerun > "$out/full-api-rerun-verify.log" 2>&1 fixture /tmp/mcp-component/tests/integration/lifecycle.php prepare > "$out/upgrade-prepare.log" 2>&1 compose exec -T joomla php /var/www/html/cli/joomla.php extension:install --path=/tmp/mcp-component.zip --no-interaction --no-ansi > "$out/upgrade-mcp.log" 2>&1 compose exec -T joomla php /var/www/html/cli/joomla.php extension:install --path=/tmp/mcp-webservices-plugin.zip --no-interaction --no-ansi > "$out/upgrade-webservices-plugin.log" 2>&1 fixture /tmp/mcp-component/tests/integration/lifecycle.php verify > "$out/upgrade-verify.log" 2>&1 fixture /tmp/mcp-component/tests/integration/installation.php > "$out/upgraded-installation.log" 2>&1 fixture /tmp/mcp-component/tests/integration/lifecycle.php uninstall > "$out/uninstall.log" 2>&1 -printf '%s\n' 'Native golden-image installation, administrator-to-MCP, HTTP ACL, stdio CRUD, exact JCB inventory, actual package/compiler jobs, owned ZIP downloads, configured remote HTTPS bridge, upgrade and uninstall tests passed.' > "$out/summary.txt" +printf '%s\n' 'Native golden-image installation, administrator-to-MCP, HTTP ACL, stdio CRUD, exact JCB inventory, actual package/compiler jobs, owned ZIP downloads, configured remote HTTPS bridge, full supplied JCB API inventory and repeated sync, upgrade and uninstall tests passed.' > "$out/summary.txt" cat "$out/summary.txt" diff --git a/tests/integration/generated-api.php b/tests/integration/generated-api.php new file mode 100644 index 0000000..b79edeb --- /dev/null +++ b/tests/integration/generated-api.php @@ -0,0 +1,157 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +use Joomla\CMS\Application\ApiApplication; +use Joomla\CMS\Event\Application\BeforeApiRouteEvent; +use Joomla\CMS\Extension\ExtensionHelper; +use Joomla\CMS\Factory; +use Joomla\CMS\Plugin\PluginHelper; +use Joomla\CMS\Router\ApiRouter; +use Joomla\CMS\User\UserFactoryInterface; +use Joomla\Database\DatabaseInterface; +use Joomla\Event\DispatcherInterface; +use VDM\Component\JoomEngineMcp\Administrator\Database\JoomlaStore; +use VDM\Component\JoomEngineMcp\Administrator\Database\Structure; +use VDM\Component\JoomEngineMcp\Administrator\Installer\Assets; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\ApiRegistry; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\CatalogueSynchronizer; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\GeneratedApiInventory; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\RegistrationObserver; +use VDM\Component\JoomEngineMcp\Administrator\Security\JoomlaPrincipal; +use VDM\Component\JoomEngineMcp\Administrator\Service\Json; + + +require __DIR__ . '/bootstrap.php'; +require __DIR__ . '/HttpFixture.php'; +$factory = $app->bootComponent('com_joomengine_mcp')->getMVCFactory(); +$db = $container->get(DatabaseInterface::class); +$admin = $container->get(UserFactoryInterface::class)->loadUserByUsername('mcp_test_admin'); +$app->loadIdentity($admin); +$store = new JoomlaStore($db); +$principal = new JoomlaPrincipal($admin); +$client = new HttpFixture((string) getenv('MCP_TEST_BASE_URL'), trim(file_get_contents((string) getenv('MCP_TEST_TOKEN_FILE')))); +$checks = 0; +$providerId = null; +$baseline = $store->find('provider', [], 1000); +$coreBinding = $store->one('binding', ['name' => 'contacts.contacts.list.api']); +$check = static function (bool $condition, string $label) use (&$checks): void +{ + if (!$condition) + { + throw new RuntimeException($label); + } + $checks++; + echo 'PASS ' . $label . PHP_EOL; +}; + +// This exercises the generic adapter with the actual installed Joomla contact +// plugin. It does not claim a generated JCB component or GUID CRUD endpoint. +$extensions = $db->setQuery($db->createQuery()->select($db->quoteName(['type', 'element', 'enabled'])) + ->from($db->quoteName('#__extensions'))->where($db->quoteName('type') . ' = ' . $db->quote('component')))->loadAssocList(); +$coreComponents = array_column(array_filter(ExtensionHelper::getCoreExtensions(), + static fn (array $extension): bool => $extension[0] === 'component'), 1); +$automatic = GeneratedApiInventory::components($extensions, $coreComponents); +$check(!in_array('com_contact', $automatic, true) && !in_array('com_banners', $automatic, true) + && !in_array('com_content', $automatic, true), + 'Production generated-component selection excludes the native Joomla core component identities'); +$check($coreBinding !== null, 'Existing Joomla contact API binding is available before the scoped test'); +$check($store->one('provider', ['name' => 'jcb.component.contact.installed']) === null, + 'The disposable explicit contact-provider scope does not overwrite an existing integration'); + +try +{ + $api = $container->get(ApiApplication::class); + $api->loadIdentity($admin); + Factory::$application = $api; + + try + { + $dispatcher = $container->get(DispatcherInterface::class); + $router = new ApiRouter($api); + PluginHelper::importPlugin('webservices', null, true, $dispatcher); + $owners = (new RegistrationObserver())->dispatch($dispatcher, + new BeforeApiRouteEvent('onBeforeApiRoute', ['router' => $router, 'subject' => $api]), static fn (): array => $router->getRoutes()); + $inventory = (new ApiRegistry($router, $owners, ['com_contact']))->inventory(); + $inventory = GeneratedApiInventory::enrich($inventory, JPATH_ADMINISTRATOR . '/components', JPATH_ROOT . '/api/components'); + } + finally + { + Factory::$application = $app; + } + + $check($inventory['routes'] !== [], 'Actual installed contact webservices registration supplies the explicit generic inventory'); + $registered = []; + + foreach ($inventory['routes'] as $route) + { + $registered[$route['method'] . ' ' . $route['route']] = $route; + $check($route['defaults']['component'] === 'com_contact' && $route['required_extensions'] !== [], + 'Native route ownership and plugin provenance remain attached to the generic contract'); + } + + $changes = (new CatalogueSynchronizer($store, [new Assets($db, $store), 'synchronize'])) + ->synchronize(['commands' => [], 'unsupported' => []], $inventory, $principal); + $provider = $store->one('provider', ['name' => 'jcb.component.contact.installed']); + $providerId = $provider === null ? null : (int) $provider['id']; + $check($providerId !== null && $changes['apiRoutes'] === count($registered), + 'Generic inventory persists its provider and complete observed route graph in Joomla'); + + foreach ($baseline as $existing) + { + $check($store->one('provider', ['id' => (int) $existing['id']]) === $existing, + 'Partial component synchronization preserves existing provider ' . $existing['name']); + } + $check($store->one('binding', ['name' => 'contacts.contacts.list.api']) === $coreBinding, + 'Generic synchronization preserves the existing Joomla contact API binding'); + + $read = null; + foreach ($store->find('binding', ['provider_id' => $providerId], 1000) as $binding) + { + $config = Json::decode($binding['configuration']); + $native = $registered[$config['method'] . ' ' . $config['route']] ?? null; + $check($native !== null, 'Persisted generic binding refers to an actual installed method/path'); + if ($config['method'] === 'GET' && $config['route'] === '/v1/contacts') + { + $read = $store->one('action', ['id' => (int) $binding['action_id']]); + } + } + $check($read !== null && $read['domain'] === 'component_api', 'Installed contact list resolves through the generic provider action'); + $client->initialize(); + $input = ['offset' => 0, 'limit' => 1, 'filter' => (object) ['search' => 'mcp-generic-no-match-' . bin2hex(random_bytes(6))]]; + $response = $client->tool('joomla_action_read', ['action' => $read['name'], 'transport' => 'api', 'input' => (object) $input]); + $check(($response['response']['status'] ?? null) === 200 && is_array($response['response']['data'] ?? null), + 'Persisted generic action and nested filter execute the actual authenticated Joomla contact HTTP API'); + $core = $client->tool('joomla_action_read', ['action' => 'contacts.contacts.list', 'transport' => 'api', 'input' => ['offset' => 0, 'limit' => 1]]); + $check(($core['response']['status'] ?? null) === 200, 'The existing Joomla MCP contact API action still executes after generic synchronization'); +} +finally +{ + $client->disconnect(); + $ownedProvider = $store->one('provider', ['name' => 'jcb.component.contact.installed']); + $providerId = $ownedProvider === null ? null : (int) $ownedProvider['id']; + if ($providerId !== null) + { + foreach (array_reverse(array_keys(Structure::definitions())) as $entity) + { + $records = $entity === 'provider' ? [$store->one('provider', ['id' => $providerId])] + : $store->find($entity, ['provider_id' => $providerId], 1000); + foreach ($records as $record) + { + $model = $factory->createModel(ucfirst($entity), 'Administrator', ['ignore_request' => true]); + $model->setCurrentUser($admin); + $ids = [(int) $record['id']]; + $check($model->publish($ids, -2) && $model->delete($ids), 'Native cleanup removes the owned generic ' . $entity . ' definition'); + } + } + $check($store->one('provider', ['id' => $providerId]) === null, 'No explicitly scoped generic contact provider remains'); + } +} + +echo Json::encode(['checks' => $checks, 'nativeRegisteredApiGenericAdapter' => true, 'persistedCatalogue' => true, + 'liveHttpRead' => true, 'generatedComponentGuidCrud' => 'not exercised', 'joomla' => JVERSION]) . PHP_EOL; diff --git a/tests/integration/run.sh b/tests/integration/run.sh index 6e77b72..68d1238 100644 --- a/tests/integration/run.sh +++ b/tests/integration/run.sh @@ -73,7 +73,7 @@ for attempt in $(seq 1 50); do if curl --silent --output /dev/null "$MCP_TEST_BASE_URL/api/index.php"; then break; fi sleep 0.2 done -for suite in installation administration admin-pagination runtime-options http field-defaults write-verification custom-field-verification message-snapshot template-styles menu-components native-lists content-languages catalogue-mcp acl-mcp stdio stdio-endurance browser browser-pagination catalogue-page-limits jcb-api job-worker; do +for suite in installation administration admin-pagination runtime-options http field-defaults write-verification custom-field-verification message-snapshot template-styles menu-components native-lists content-languages catalogue-mcp acl-mcp stdio stdio-endurance browser browser-pagination catalogue-page-limits jcb-api generated-api job-worker; do php "$root/tests/integration/$suite.php" | tee "$root/build/evidence/live-$suite.log" done if [[ -n "${MCP_PLUGIN_SOURCE:-}" ]]; then diff --git a/tests/inventory-transport.php b/tests/inventory-transport.php new file mode 100644 index 0000000..73c816a --- /dev/null +++ b/tests/inventory-transport.php @@ -0,0 +1,230 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\CatalogueBuilder; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\InventoryTransport; +use VDM\Component\JoomEngineMcp\Administrator\Process\PhpProcess; +use VDM\Component\JoomEngineMcp\Administrator\Security\SchemaValidator; +use VDM\Component\JoomEngineMcp\Administrator\Service\Json; + +require dirname(__DIR__) . '/admin/autoload.php'; +$checks = 0; +$check = static function (bool $condition, string $message) use (&$checks): void +{ + if (!$condition) + { + throw new RuntimeException($message); + } + $checks++; +}; +$fails = static function (callable $operation, string $identifier, string $message) use ($check): void +{ + try + { + $operation(); + } + catch (OperationException $error) + { + $check($error->getIdentifier() === $identifier, $message . ': ' . $error->getIdentifier()); + return; + } + throw new RuntimeException($message); +}; +$withManifest = static function (array $wire, callable $change): array +{ + $manifest = Json::native(Json::decode($wire['inventory'], false)); + $change($manifest); + $wire['inventory'] = Json::canonical($manifest); + return $wire; +}; + +foreach ([[], (object) [], [1, 2], (object) ['0' => 1.0, '1' => 2], + ['z' => ['b' => true, 'a' => null], 'a' => ['雪', 'literal / slash', 1.0, 1, false]]] as $value) +{ + $check(Json::canonicalHash($value) === hash('sha256', Json::canonical($value)), + 'Incremental canonical hashing matches exact existing bytes and JSON shapes.'); +} +$check(Json::canonicalHash((object) []) !== Json::canonicalHash([]) + && Json::canonicalHash((object) ['0' => 1, '1' => 2]) !== Json::canonicalHash([1, 2]) + && Json::canonicalHash(1.0) !== Json::canonicalHash(1), 'Canonical hashing preserves ambiguous object/list and numeric representations.'); +$check(Json::canonicalHash(['b' => 2, 'a' => 1]) === Json::canonicalHash(['a' => 1, 'b' => 2]) + && Json::canonicalHash([1, 2]) !== Json::canonicalHash([2, 1]), 'Canonical maps sort while native list order remains meaningful.'); +$deep = []; +for ($depth = 0; $depth < 66; $depth++) +{ + $deep = [$deep]; +} +$fails(static fn () => Json::canonicalHash($deep), 'RESULT_INVALID', 'Excessive canonical nesting fails closed.'); +$cyclic = new stdClass(); +$cyclic->cycle = $cyclic; +$fails(static fn () => Json::canonicalHash($cyclic), 'RESULT_INVALID', 'Cyclic inventory objects fail at bounded nesting.'); +$fails(static fn () => Json::canonicalHash(INF), 'RESULT_INVALID', 'Unencodable numeric values never produce a success fingerprint.'); +$chunk = str_repeat('b', 1048576); +$aggregate = array_fill(0, 65, $chunk); +$fails(static fn () => Json::canonicalHash($aggregate), 'RESULT_TOO_LARGE', 'Canonical aggregate hashing remains bounded at64MiB.'); +unset($aggregate, $chunk, $deep, $cyclic); + +$process = new PhpProcess(PHP_BINARY, __DIR__ . '/fixtures/inventory-child.php', __DIR__); +try +{ + $process->run(['routes' => 80], 10, InventoryTransport::MAX_WIRE_BYTES, static fn (): bool => false); + throw new RuntimeException('The historical full repeated worker envelope must exceed8MiB.'); +} +catch (OperationException $error) +{ + $check(in_array($error->getIdentifier(), ['WORKER_OUTPUT_LIMIT', 'WORKER_FAILED'], true), + 'Historical full inventory is rejected by the unchanged8MiB worker output bound.'); +} +$wire = $process->run(['routes' => 80, 'inventory_format' => InventoryTransport::FORMAT], 10, + InventoryTransport::MAX_WIRE_BYTES, static fn (): bool => false); +$manifest = Json::native(Json::decode($wire['inventory'], false)); +$check(count($wire['form_contracts']) === 2 && count($manifest['api']['routes']) === 80 + && strlen(Json::encode($wire)) < 1048576, 'Actual bounded subprocess returns all80routes using two distinct native form contracts.'); +$restored = InventoryTransport::unpack($wire); +$check(count($restored['api']['routes']) === 80 && !isset($restored['inventory_format'], $restored['form_contracts']), + 'Expansion returns the full native inventory without transport references.'); +$check($restored['api']['components'] === ['com_example'] + && $restored['api']['unsupported_components'] === ['PUT /v1/example/custom' => 'com_example'] + && $restored['api']['unsupported'] === ['PUT /v1/example/custom' => 'Reviewed adapter required.'], + 'Component scopes and unsupported owner diagnostics survive isolated IPC unchanged.'); +$check($restored['api']['native_observation']['empty'] instanceof stdClass + && $restored['api']['native_observation']['numeric'] instanceof stdClass + && $restored['api']['native_observation']['numeric']->{'0'} === 1.0 + && $restored['api']['routes'][0]['defaults']['rate'] === 1.0, + 'Manifest JSON preserves nested empty and numeric-key objects and floats outside form contracts.'); +$check($restored['api']['fingerprint'] === Json::canonicalHash(['routes' => $restored['api']['routes'], + 'unsupported' => $restored['api']['unsupported'], 'components' => $restored['api']['components']]), + 'Expanded source inventory reproduces its worker-side fingerprint beyond8MiB.'); +$first = $restored['api']['routes'][0]['form_contract']; +$second = $restored['api']['routes'][1]['form_contract']; +$check($first['schema']['required'] === ['title'] && !isset($second['schema']['required']) + && $first['fingerprint'] !== $second['fingerprint'], 'POST requirements and PATCH omission semantics remain separate exact contracts.'); +$check($first['empty_object'] instanceof stdClass && $first['empty_list'] === [] + && $first['numeric_object'] instanceof stdClass && $first['numeric_object']->{'0'} === 1.0 + && $first['schema']['properties']['custom'] instanceof stdClass, + 'Contract strings retain empty objects, lists, numeric-key objects and floats across associative worker decoding.'); +$validator = new SchemaValidator(); +$check($validator->input(['title' => 'Accepted native input'], Json::encode($first['schema'])) === ['title' => 'Accepted native input'], + 'Expanded native create schema accepts valid input without inserting omitted fields.'); +$fails(static fn () => $validator->input([], Json::encode($first['schema'])), 'INVALID_INPUT', 'Expanded native create schema still rejects missing required input.'); +$check($validator->input([], Json::encode($second['schema'])) === [], 'Expanded PATCH schema preserves omitted input.'); +$seed = (new CatalogueBuilder())->build($restored['commands'], $restored['api']); +$check($seed['source'] === Json::canonicalHash(['commands' => $restored['commands'], 'api' => $restored['api']]) + && count($seed['entities']['binding']) === 80, 'Production catalogue materializes every large-inventory route with the unchanged canonical source hash.'); +$binding = Json::decode($seed['entities']['binding'][0]['configuration'], false); +$check($binding->api_form->fingerprint === $first['fingerprint'] + && $binding->api_form->schema->properties->custom instanceof stdClass, + 'Persisted API bindings retain full source fingerprints and native schema object shapes.'); +$check($wire === $process->run(['routes' => 80, 'inventory_format' => InventoryTransport::FORMAT], 10, + InventoryTransport::MAX_WIRE_BYTES, static fn (): bool => false), 'Repeated installed-style inventory exchanges are deterministic.'); + +$bad = $withManifest($wire, static function (array &$manifest): void +{ + array_shift($manifest['api']['routes']); +}); +$fails(static fn () => InventoryTransport::unpack($bad), 'JCB_INVENTORY_INVALID', 'Dropping a route with a still-used contract cannot preserve the complete observed inventory fingerprint.'); +$bad = $withManifest($wire, static function (array &$manifest): void +{ + $first = $manifest['api']['routes'][0]['form_contract_ref']; + $manifest['api']['routes'][0]['form_contract_ref'] = $manifest['api']['routes'][1]['form_contract_ref']; + $manifest['api']['routes'][1]['form_contract_ref'] = $first; +}); +$fails(static fn () => InventoryTransport::unpack($bad), 'JCB_INVENTORY_INVALID', 'Swapping valid POST and PATCH contract references cannot retain the complete inventory fingerprint.'); +$bad = $withManifest($wire, static function (array &$manifest): void +{ + $manifest['api']['components'] = ['com_other']; + $manifest['api']['unsupported_components']['PUT /v1/example/custom'] = 'com_other'; +}); +$fails(static fn () => InventoryTransport::unpack($bad), 'JCB_INVENTORY_INVALID', 'Altered component scopes and diagnostic owners are detected before synchronization.'); +$bad = $withManifest($wire, static function (array &$manifest): void +{ + $manifest['commands']['unsupported']['componentbuilder:changed'] = 'Changed diagnostic.'; +}); +$fails(static fn () => InventoryTransport::unpack($bad), 'JCB_INVENTORY_INVALID', 'Command metadata is covered by the same complete inventory fingerprint.'); +$bad = $wire; +unset($bad['inventory_fingerprint']); +$fails(static fn () => InventoryTransport::unpack($bad), 'JCB_INVENTORY_INVALID', 'A compact inventory without complete source integrity evidence is rejected.'); +$bad = $withManifest($wire, static function (array &$manifest): void +{ + $manifest['api']['routes'][0]['form_contract_ref'] = str_repeat('0', 64); +}); +$fails(static fn () => InventoryTransport::unpack($bad), 'JCB_INVENTORY_INVALID', 'Missing dictionary references reject the entire inventory.'); +$bad = $wire; +$reference = array_key_first($bad['form_contracts']); +$bad['form_contracts'][$reference] .= ' '; +$fails(static fn () => InventoryTransport::unpack($bad), 'JCB_INVENTORY_INVALID', 'Altered dictionary bytes cannot retain a content fingerprint.'); +$bad = $wire; +$contract = Json::decode($bad['form_contracts'][$reference], false); +$contract->fingerprint = str_repeat('0', 64); +$text = Json::canonical($contract); +$replacement = hash('sha256', $text); +unset($bad['form_contracts'][$reference]); +$bad['form_contracts'][$replacement] = $text; +$badManifest = Json::native(Json::decode($bad['inventory'], false)); +foreach ($badManifest['api']['routes'] as &$route) +{ + if ($route['form_contract_ref'] === $reference) + { + $route['form_contract_ref'] = $replacement; + } +} +unset($route); +$bad['inventory'] = Json::canonical($badManifest); +$fails(static fn () => InventoryTransport::unpack($bad), 'JCB_INVENTORY_INVALID', 'A new dictionary hash cannot hide an altered native source fingerprint.'); +$bad = $withManifest($wire, static function (array &$manifest): void +{ + $manifest['api']['routes'] = array_fill(0, 600, $manifest['api']['routes'][0]); +}); +$fails(static fn () => InventoryTransport::unpack($bad), 'JCB_INVENTORY_LIMIT', 'Repeated compact references cannot exceed the bounded64MiB expansion.'); +$bad = $withManifest($wire, static function (array &$manifest): void +{ + $manifest['api']['routes'] = array_fill(0, InventoryTransport::MAX_ROUTES + 1, $manifest['api']['routes'][0]); +}); +$fails(static fn () => InventoryTransport::unpack($bad), 'JCB_INVENTORY_INVALID', 'Native route cardinality is bounded before expansion.'); +$bad = $withManifest($wire, static function (array &$manifest) use ($first): void +{ + $manifest['api']['routes'][0]['form_contract'] = $first; +}); +$fails(static fn () => InventoryTransport::unpack($bad), 'JCB_INVENTORY_INVALID', 'Mixed compact and expanded route contracts fail closed.'); +$bad = $wire; +$bad['inventory_format'] = 'unknown'; +$fails(static fn () => InventoryTransport::unpack($bad), 'JCB_INVENTORY_INVALID', 'Unsupported inventory encodings are rejected explicitly.'); +$small = $restored; +$small['api']['routes'] = array_slice($small['api']['routes'], 0, 2); +$packed = InventoryTransport::pack($small); +$roundtrip = InventoryTransport::unpack(Json::decode(Json::encode($packed))); +$check(Json::canonicalHash($small) === Json::canonicalHash($roundtrip), 'Compact encoding roundtrips the exact full inventory value without truncation.'); +$unused = $packed; +$unused = $withManifest(Json::decode(Json::encode($unused)), static function (array &$manifest): void +{ + $manifest['api']['routes'] = array_slice($manifest['api']['routes'], 0, 1); +}); +$fails(static fn () => InventoryTransport::unpack($unused), 'JCB_INVENTORY_INVALID', 'Unrelated dictionary entries cannot enter a partial inventory.'); +$empty = ['commands' => ['commands' => []], 'api' => ['routes' => [], 'components' => ['com_example'], 'unsupported' => []]]; +$check(Json::canonicalHash($empty) === Json::canonicalHash(InventoryTransport::unpack(Json::decode(Json::encode(InventoryTransport::pack($empty))))), + 'An authoritative empty component scope roundtrips without inventing or truncating routes.'); +$unicode = $small; +$unicode['api']['routes'] = array_slice($unicode['api']['routes'], 0, 1); +$unicodeContract = $unicode['api']['routes'][0]['form_contract']; +unset($unicodeContract['fingerprint']); +$unicodeContract['fields']['title']['hint'] = str_repeat('雪', 1400000); +$unicodeContract['fingerprint'] = Json::canonicalHash($unicodeContract); +$unicode['api']['routes'][0]['form_contract'] = $unicodeContract; +$unicodePacked = InventoryTransport::pack($unicode); +$unicodeWire = Json::encode($unicodePacked, InventoryTransport::MAX_WIRE_BYTES); +$check(strlen($unicodeWire) < InventoryTransport::MAX_WIRE_BYTES + && strlen(json_encode($unicodePacked, JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)) > InventoryTransport::MAX_WIRE_BYTES, + 'The compact final encoder preserves UTF-8 and uses the same checked byte budget even where escaped Unicode would overflow.'); +$check(Json::canonicalHash($unicode) === Json::canonicalHash(InventoryTransport::unpack(Json::decode($unicodeWire))), + 'Near-boundary Unicode contract evidence survives the exact compact final encoding and worker decoding.'); +unset($unicode, $unicodeContract, $unicodePacked, $unicodeWire); +$fails(static fn () => Json::encode($restored), 'RESULT_TOO_LARGE', 'Ordinary HTTP and result JSON still enforce their unchanged8MiB budget.'); + +echo Json::encode(['checks' => $checks, 'inventoryTransport' => 'passed']) . PHP_EOL; diff --git a/tests/jcb-api-verification.php b/tests/jcb-api-verification.php new file mode 100644 index 0000000..644c630 --- /dev/null +++ b/tests/jcb-api-verification.php @@ -0,0 +1,366 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +use Http\Client\HttpClient; +use Nyholm\Psr7\Response; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; +use VDM\Component\JoomEngineMcp\Administrator\Handler\ApiHandler; +use VDM\Component\JoomEngineMcp\Administrator\Handler\ApiRequestBuilder; +use VDM\Component\JoomEngineMcp\Administrator\Protocol\ToolDispatcher; +use VDM\Component\JoomEngineMcp\Administrator\Security\Authorizer; +use VDM\Component\JoomEngineMcp\Administrator\Security\Envelope; +use VDM\Component\JoomEngineMcp\Administrator\Security\SchemaValidator; +use VDM\Component\JoomEngineMcp\Administrator\Service\ActionExecutor; +use VDM\Component\JoomEngineMcp\Administrator\Service\ApiWriteVerification; +use VDM\Component\JoomEngineMcp\Administrator\Service\Catalogue; +use VDM\Component\JoomEngineMcp\Administrator\Service\HandlerRegistry; +use VDM\Component\JoomEngineMcp\Administrator\Service\Json; +use VDM\Component\JoomEngineMcp\Administrator\Service\Settings; +use VDM\Component\JoomEngineMcp\Administrator\State\Audit; +use VDM\Component\JoomEngineMcp\Administrator\State\Executions; +use VDM\Component\JoomEngineMcp\Administrator\State\Permissions; +use VDM\Component\JoomEngineMcp\Tests\Support\MemoryStore; +use VDM\Component\JoomEngineMcp\Tests\Support\Principal; + +require dirname(__DIR__) . '/admin/autoload.php'; +require __DIR__ . '/Support/MemoryStore.php'; +require __DIR__ . '/Support/Principal.php'; +set_error_handler(static function (int $severity, string $message, string $file, int $line): never +{ + throw new ErrorException($message, 0, $severity, $file, $line); +}); +$checks = 0; +$check = static function (bool $value, string $message) use (&$checks): void +{ + $checks++; + + if (!$value) + { + throw new RuntimeException($message); + } +}; +$seed = Json::decode(file_get_contents(dirname(__DIR__) . '/admin/data/catalogue-seed.json'))['entities']; +$guid = '9cfb0aca-8c0f-4a04-b2b0-a369ed5c260e'; +$contracts = ['fields' => [ + 'published' => ['representation' => 'integer'], + 'options' => ['representation' => 'json', 'properties' => [ + 'rows' => ['representation' => 'array', 'items' => ['representation' => 'object', + 'properties' => ['enabled' => ['representation' => 'integer']]]]]], +], 'request_only' => ['add_php']]; + +/** Independent responses exercise the complete approval, claim and settlement path. */ +$fixture = static function (string $identity, array $record, array $mutation, bool $deleted = false, array $generation = []) use ($seed, $contracts): array +{ + $rows = $seed; + $key = $identity === 'integer' ? 'id' : ($identity === 'guid' ? 'guid' : 'key'); + $kind = $identity === 'integer' ? 'positive-integer' : ($identity === 'guid' ? 'guid' : 'unique-key'); + $schemaIds = []; + $actions = []; + + foreach ($rows['action'] as &$action) + { + if (str_starts_with($action['name'], 'content.categories.')) + { + $operation = substr($action['name'], strrpos($action['name'], '.') + 1); + $action['name'] = 'generated.widgets.' . $operation; + $actions[$action['id']] = $operation; + $schemaIds[$action['input_schema_id']] = $operation; + } + } + unset($action); + $idSchema = $identity === 'integer' ? ['type' => 'integer', 'minimum' => 1] + : ['type' => 'string', 'minLength' => 1, 'maxLength' => 255]; + + foreach ($rows['schema'] as &$schema) + { + if (isset($schemaIds[$schema['id']])) + { + $operation = $schemaIds[$schema['id']]; + $properties = $operation === 'create' ? [] : [$key => $idSchema]; + $required = array_keys($properties); + + if (in_array($operation, ['create', 'update'], true)) + { + $properties['data'] = ['type' => 'object', 'minProperties' => 1, 'additionalProperties' => true]; + + if ($operation === 'create' && isset($generation['guid'])) + { + $properties['data']['properties'] = ['guid' => ['type' => 'string', 'format' => 'uuid']]; + $properties['data']['required'] = ['guid']; + } + $required[] = 'data'; + } + $schema['document'] = Json::encode(['type' => 'object', 'properties' => (object) $properties, + 'required' => $required, 'additionalProperties' => false]); + } + } + unset($schema); + $readBindingId = null; + + foreach ($rows['binding'] as &$binding) + { + if (isset($actions[$binding['action_id']]) && $binding['track'] === 'api') + { + $operation = $actions[$binding['action_id']]; + $config = Json::decode($binding['configuration']); + $config['route'] = '/v1/example/widgets' . ($operation === 'create' ? '' : '/:' . $key); + $config['route_parameters'] = $operation === 'create' ? [] : [['name' => $key, 'kind' => $kind]]; + $config['body_defaults'] = (object) []; + $config['query_defaults'] = (object) []; + $config['preserve_fields'] = []; + $config['read_action'] = 'generated.widgets.get'; + $config['api_form'] = ['verification' => $contracts, 'generation' => $generation]; + $binding['configuration'] = Json::encode($config); + $binding['params'] = Json::encode(['verification' => ['operation' => $operation, 'read_action' => 'generated.widgets.get', + 'primary_key' => $key, 'input_key' => $key, 'read_input_key' => $key, 'identity_type' => $identity]]); + $binding['definition'] = Json::encode(['nativeRoute' => ['route' => $config['route'], + 'controller' => 'widgets.' . ($operation === 'get' ? 'displayItem' : $operation), 'defaults' => ['component' => 'com_example']]]); + + if ($operation === 'get') + { + $readBindingId = $binding['id']; + } + } + } + unset($binding); + $http = new class implements HttpClient + { + /** @var array Independent item read-back. */ + public array $record = []; + /** @var array Accepted mutation item. */ + public array $mutation = []; + /** @var bool Whether deletion makes the item unavailable. */ + public bool $deleted = false; + /** @var int Native writes performed. */ + public int $writes = 0; + /** @var array Captured requests without headers or credentials. */ + public array $requests = []; + /** @var ?Closure Callback simulating a definition change during the native mutation. */ + public ?Closure $afterWrite = null; + /** @inheritDoc */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + $method = $request->getMethod(); + $this->requests[] = ['method' => $method, 'path' => $request->getUri()->getPath()]; + + if ($method === 'GET' && $this->deleted && $this->writes > 0) + { + return new Response(404, ['Content-Type' => 'application/vnd.api+json'], Json::encode(['errors' => [['status' => '404']]])); + } + + if ($method !== 'GET') + { + $this->writes++; + + if ($this->afterWrite !== null) + { + ($this->afterWrite)(); + } + } + $item = $method === 'GET' ? $this->record : $this->mutation; + + return new Response($method === 'DELETE' ? 204 : 200, ['Content-Type' => 'application/vnd.api+json'], + $method === 'DELETE' ? '' : Json::encode(['data' => ['id' => $item['id'] ?? '87', 'attributes' => $item]])); + } + }; + $http->record = $record; + $http->mutation = $mutation; + $http->deleted = $deleted; + $store = new MemoryStore($rows); + $principal = new Principal('joomla:17', 'api', [1]); + $settings = new Settings(['api_base' => 'https://joomla.example/api/index.php']); + $schemas = new SchemaValidator(); + $catalogue = new Catalogue($store, new Authorizer(), $principal, $schemas, $settings, + static fn (string $name): bool => true, static fn (string $entity, string $handler): bool => true); + $clock = static fn (): int => 1900000000; + $audit = new Audit($store, $principal, $clock); + $permissions = new Permissions($store, $principal, $catalogue, $settings, $audit, $clock); + $state = new Executions($store, $principal, new Envelope(str_repeat('s', 32)), $permissions, $settings, $audit, $clock); + $builder = new ApiRequestBuilder(); + $handler = new ApiHandler($http, $builder, $settings, 'test-token', static fn (): string => 'unused'); + $executor = new ActionExecutor($catalogue, $schemas, $principal, new HandlerRegistry(['api.request' => $handler]), $permissions, $state, $audit, $settings, $builder); + $request = $permissions->request(['toolsets' => ['structure.write'], 'duration' => '30-minutes', 'reason' => 'Generated API read-back contract tests']); + $permissions->approve($request['requestId'], $request['acknowledgement']); + $dispatcher = new ToolDispatcher($catalogue, $executor, $permissions, $schemas, $principal, $settings); + + return compact('http', 'store', 'catalogue', 'executor', 'state', 'dispatcher', 'key', 'readBindingId'); +}; +$run = static function (array $fixture, string $operation, array $input, string $status) use ($check): array +{ + $plan = $fixture['executor']->plan('generated.widgets.' . $operation, $input, Json::uuid()); + $check($fixture['http']->writes === 0, 'Planning never performs a mutation.'); + $result = $fixture['executor']->apply($plan['confirmationToken']); + $check($result['verification']['status'] === $status, 'Unexpected generated verification: ' . Json::encode($result['verification'])); + $check($fixture['http']->writes === 1, 'Apply performs exactly one write.'); + $check(($fixture['store']->find('lease') === []) === ($status !== 'uncertain'), 'Uncertain generated writes retain their lease.'); + $count = count($fixture['http']->requests); + $replay = $fixture['executor']->apply($plan['confirmationToken']); + $check($replay['idempotentReplay'] && count($fixture['http']->requests) === $count, 'Reapplying never performs new read or mutation I/O.'); + + return $result; +}; + +foreach (['integer' => 87, 'guid' => $guid, 'string' => '0012'] as $type => $identity) +{ + $key = $type === 'integer' ? 'id' : ($type === 'guid' ? 'guid' : 'key'); + $record = [$key => $identity, 'name' => 'Example', 'published' => '1']; + $f = $fixture($type, $record, $record); + $run($f, 'create', ['data' => ['name' => 'Example', 'published' => 1]], 'verified'); + $check(end($f['http']->requests)['path'] === '/api/index.php/v1/example/widgets/' . $identity, 'Created identity selects its exact declared read route.'); + $run($fixture($type, $record, $record), 'update', [$key => $identity, 'data' => ['published' => 1]], 'verified'); + $run($fixture($type, $record, [], true), 'delete', [$key => $identity], 'verified'); + $wrong = array_replace($record, [$key => $type === 'integer' ? 88 : ($type === 'guid' ? Json::uuid() : '12')]); + $run($fixture($type, $wrong, $record), 'create', ['data' => ['name' => 'Example']], 'uncertain'); +} +$run($fixture('guid', ['guid' => strtoupper($guid), 'name' => 'Example'], ['guid' => $guid]), 'update', + ['guid' => $guid, 'data' => ['name' => 'Example']], 'verified'); +$f = $fixture('guid', ['guid' => $guid, 'name' => 'Example'], ['name' => 'Example']); +$run($f, 'create', ['data' => ['guid' => $guid, 'name' => 'Example']], 'verified'); +$run($fixture('guid', ['name' => 'Example'], ['name' => 'Example']), 'create', ['data' => ['name' => 'Example']], 'uncertain'); +$run($fixture('guid', ['guid' => $guid, 'name' => 'Example'], ['guid' => 'not-a-guid']), 'create', ['data' => ['name' => 'Example']], 'uncertain'); +$f = $fixture('guid', ['guid' => $guid, 'name' => 'Example'], ['guid' => Json::uuid()]); +$run($f, 'update', ['guid' => $guid, 'data' => ['name' => 'Example']], 'uncertain'); +$check(count(array_filter($f['http']->requests, static fn (array $request): bool => $request['method'] === 'GET')) === 2, + 'A mismatched mutation identity prevents a read of a different resource.'); +$f = $fixture('guid', ['guid' => Json::uuid(), 'name' => 'Example'], ['guid' => $guid]); +try +{ + $f['executor']->plan('generated.widgets.update', ['guid' => $guid, 'data' => ['name' => 'Example']], Json::uuid()); + throw new RuntimeException('A snapshot of another resource must be rejected.'); +} +catch (OperationException $error) +{ + $check($error->getIdentifier() === 'PRECONDITION_CHANGED' && $f['http']->writes === 0, + 'A mismatched GUID snapshot prevents creating an executable write plan.'); +} +$f = $fixture('guid', ['guid' => $guid, 'name' => 'Example', 'add_php' => 1], ['guid' => $guid]); +$result = $run($f, 'create', ['data' => ['name' => 'Example', 'add_php' => 1, 'write_only' => 'inert fixture']], 'partial'); +$check($result['verification']['requestOnlyFields'] === ['add_php'] && $result['verification']['unobservableFields'] === ['write_only'] + && $result['verification']['matchedFields'] === ['name'], 'Request-only controls are distinct from matched and unobservable saved fields.'); + +$f = $fixture('guid', ['guid' => $guid, 'name' => 'Example'], ['guid' => $guid]); +$write = $f['catalogue']->action('generated.widgets.create'); +$read = $f['catalogue']->action('generated.widgets.get'); +$check(ApiWriteVerification::compare($write, $read, 'options', ['rows' => [['enabled' => 1]]], '{"rows":[{"enabled":"1"}]}') === true, + 'Declared recursive integer representations verify independently stored JSON.'); +foreach ([ + ['rows' => [['enabled' => '01']]], ['rows' => [['enabled' => true]]], ['rows' => [['enabled' => 1, 'additional' => 'value']]], + ['rows' => (object) ['0' => ['enabled' => 1]]], ['rows' => [['enabled' => 1], ['enabled' => 1]]], +] as $observed) +{ + $check(ApiWriteVerification::compare($write, $read, 'options', ['rows' => [['enabled' => 1]]], $observed) === false, + 'Declared child conversion never hides malformed values or changed shape.'); +} +$check(ApiWriteVerification::compare($write, $read, 'untyped', ['value' => 1], ['value' => '1']) === false, + 'Undeclared nested numeric fields retain exact JSON comparison.'); +$named = $write; +$named['binding']['configuration']['api_form']['verification']['fields']['named'] = ['representation' => 'json', 'ordered' => true, + 'additionalProperties' => ['representation' => 'object', 'properties' => ['enabled' => ['representation' => 'integer']]]]; +$desired = ['row1' => ['enabled' => 1], 'row2' => ['enabled' => 0]]; +$check(ApiWriteVerification::compare($named, $read, 'named', $desired, ['row1' => ['enabled' => '1'], 'row2' => ['enabled' => '0']]) === true + && ApiWriteVerification::compare($named, $read, 'named', $desired, ['row2' => ['enabled' => '0'], 'row1' => ['enabled' => '1']]) === false, + 'Declared ordered named subform rows retain their identity and order.'); +$changed = $read; +$changed['binding']['configuration']['route'] = '/v1/other/widgets/:guid'; +$check(ApiWriteVerification::compare($write, $changed, 'published', 1, '1') === null, + 'A customized foreign read does not inherit native form comparison contracts.'); +$check(ApiWriteVerification::identity('0012', 'integer') === null && ApiWriteVerification::identity('0012', 'string') === '0012' + && ApiWriteVerification::identity('1e2', 'integer') === null && ApiWriteVerification::identity(true, 'integer') === null, + 'Identity comparison never invents numeric equivalence for typed string keys.'); + +$f = $fixture('guid', ['guid' => $guid, 'name' => 'Example'], ['guid' => $guid]); +$plan = $f['executor']->plan('generated.widgets.create', ['data' => ['name' => 'Example']], Json::uuid()); +$binding = $f['store']->one('binding', ['id' => $f['readBindingId']]); +$config = Json::decode($binding['configuration']); +$config['route'] = '/v1/other/widgets/:guid'; +$f['store']->update('binding', ['configuration' => Json::encode($config)], ['id' => $f['readBindingId']]); +try +{ + $f['executor']->apply($plan['confirmationToken']); + throw new RuntimeException('A changed independent read must invalidate its plan.'); +} +catch (OperationException $error) +{ + $check($error->getIdentifier() === 'PLAN_STALE' && $f['http']->writes === 0, + 'Changing the independent read after approval is rejected before mutation.'); +} +$f = $fixture('guid', ['guid' => $guid, 'name' => 'Example'], ['guid' => $guid]); +$f['http']->afterWrite = static function () use ($f): void +{ + $binding = $f['store']->one('binding', ['id' => $f['readBindingId']]); + $config = Json::decode($binding['configuration']); + $config['route'] = '/v1/other/widgets/:guid'; + $f['store']->update('binding', ['configuration' => Json::encode($config)], ['id' => $f['readBindingId']]); +}; +$run($f, 'create', ['data' => ['name' => 'Example']], 'uncertain'); +$check(count($f['http']->requests) === 1, 'A changed verification definition after mutation is retained as uncertain without using the changed route.'); + +// Primary GUID generation is part of the approved input, never an apply-time effect. +$generation = ['guid' => ['kind' => 'guid', 'on' => 'create', 'format' => 'uuid-v4']]; +$f = $fixture('guid', [], [], false, $generation); +$key = '4c6d8985-7dc1-7e6d-b197-c2f732f3559d'; +$input = ['data' => ['name' => 'Example']]; +$dry = $f['executor']->plan('generated.widgets.create', $input, $key, true); +$generated = $dry['operation']['generatedFields']['guid']['value']; +$check(preg_match('/\A[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\z/D', $generated) === 1 + && $dry['operation']['generatedFields']['guid']['source'] === 'installed-native-form' + && $f['http']->writes === 0 && $f['store']->find('plan') === [], 'Dry planning discloses a v4 primary GUID without storing a plan or mutating.'); +$first = $f['executor']->plan('generated.widgets.create', $input, $key); +$second = $f['executor']->plan('generated.widgets.create', $input, strtoupper($key)); +$check($first['operation']['generatedFields'] === $second['operation']['generatedFields'] + && $first['operation']['fingerprint'] === $second['operation']['fingerprint'], + 'Repeated planning with the same normalized operation key binds the same identity and fingerprint.'); +$stored = $f['state']->resolve($first['confirmationToken']); +$check($stored['payload']['input']['data']['guid'] === $generated, + 'The generated primary GUID is frozen in the encrypted approved form input.'); +$f['http']->record = $f['http']->mutation = ['guid' => $generated, 'name' => 'Example']; +$result = $f['executor']->apply($first['confirmationToken']); +$count = count($f['http']->requests); +$replay = $f['executor']->apply($second['confirmationToken']); +$check($result['verification']['status'] === 'verified' && $result['verification']['id'] === $generated + && $replay['idempotentReplay'] && $f['http']->writes === 1 && count($f['http']->requests) === $count, + 'Two approved plans with the same generated identity execute once and replay the same independent verification.'); +$other = $f['executor']->plan('generated.widgets.create', $input, Json::uuid(), true); +$check($other['operation']['generatedFields']['guid']['value'] !== $generated, 'A new operation key creates a different primary GUID.'); +$explicit = $f['executor']->plan('generated.widgets.create', ['data' => ['name' => 'Example', 'guid' => $guid]], Json::uuid(), true); +$check(!isset($explicit['operation']['generatedFields']), 'An explicitly supplied primary GUID is preserved and never regenerated.'); +$update = $f['executor']->plan('generated.widgets.update', ['guid' => $generated, 'data' => ['name' => 'Example']], Json::uuid(), true); +$check(!isset($update['operation']['generatedFields']), 'Updates never generate a new identity even when create generation metadata is present.'); +$description = $f['executor']->describe('generated.widgets.create'); +$check($description['nativeForm']['generation'] === $generation && isset($description['inputSchema']), + 'Generated action descriptions expose the installed native form and generation contract.'); +$relations = $fixture('guid', ['guid' => $guid], ['guid' => $guid], false, ['parent_guid' => ['kind' => 'guid', 'on' => 'create', 'format' => 'uuid-v4']]); +$relation = $relations['executor']->plan('generated.widgets.create', ['data' => ['name' => 'Example']], Json::uuid(), true); +$check(!isset($relation['operation']['generatedFields']), 'Relationship GUID policies can never create or guess another resource identity.'); + +// A real wire JSON object remains an object through the generic tool schema. +$f = $fixture('guid', [], [], false, $generation); +$wire = Json::native(Json::decode(Json::encode(['action' => 'generated.widgets.create', 'transport' => 'api', + 'idempotencyKey' => Json::uuid(), 'input' => ['data' => new stdClass()]]), false)); +$check($wire['input']['data'] instanceof stdClass, 'Empty wire data is retained as an object rather than a JSON list.'); +$dry = $f['dispatcher']->call('joomla_action_write_plan', $wire + ['dryRun' => true]); +$generated = $dry['operation']['generatedFields']['guid']['value']; +$check($f['http']->writes === 0 && $f['store']->find('plan') === [], + 'An empty native form with only a required primary GUID is fully prepared through the tool schema without mutation.'); +$plan = $f['dispatcher']->call('joomla_action_write_plan', $wire); +$stored = $f['state']->resolve($plan['confirmationToken']); +$check($stored['payload']['input']['data'] === ['guid' => $generated], + 'Empty-object tool planning freezes precisely the required generated GUID.'); +$f['http']->record = $f['http']->mutation = ['guid' => $generated]; +$result = $f['dispatcher']->call('joomla_write_apply', ['confirmationToken' => $plan['confirmationToken']]); +$count = count($f['http']->requests); +$replay = $f['dispatcher']->call('joomla_write_apply', ['confirmationToken' => $plan['confirmationToken']]); +$check($result['verification']['status'] === 'verified' && $result['verification']['matchedFields'] === ['guid'] + && $replay['idempotentReplay'] && $f['http']->writes === 1 && count($f['http']->requests) === $count, + 'The empty-object wire tool plan applies once, independently verifies its GUID and replays without new I/O.'); +echo Json::encode(['checks' => $checks, 'generatedApiVerification' => 'passed with recording API and transactional state doubles', + 'liveJoomla' => 'not run by this unit suite']) . PHP_EOL; +restore_error_handler(); diff --git a/tests/jcb-form-contracts.php b/tests/jcb-form-contracts.php new file mode 100644 index 0000000..231df01 --- /dev/null +++ b/tests/jcb-form-contracts.php @@ -0,0 +1,284 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; +use VDM\Component\JoomEngineMcp\Administrator\Jcb\FormContracts; +use VDM\Component\JoomEngineMcp\Administrator\Security\SchemaValidator; +use VDM\Component\JoomEngineMcp\Administrator\Service\Json; + + +require dirname(__DIR__) . '/admin/autoload.php'; +set_error_handler(static function (int $severity, string $message, string $file, int $line): never +{ + throw new ErrorException($message, 0, $severity, $file, $line); +}); + +$checks = 0; +$check = static function (bool $condition, string $message) use (&$checks): void +{ + if (!$condition) + { + throw new RuntimeException($message); + } + $checks++; +}; +$reject = static function (callable $operation, string $identifier) use ($check): void +{ + try + { + $operation(); + } + catch (OperationException $exception) + { + $check($exception->getIdentifier() === $identifier, 'Expected diagnostic ' . $identifier); + return; + } + throw new RuntimeException('Expected diagnostic ' . $identifier); +}; +$temporary = sys_get_temp_dir() . '/mcp-native-forms-' . bin2hex(random_bytes(8)); +$write = static function (string $path, string $content) use ($temporary): void +{ + $path = $temporary . '/' . $path; + + if (!is_dir(dirname($path))) + { + mkdir(dirname($path), 0700, true); + } + file_put_contents($path, $content); +}; +$remove = static function (string $path) use (&$remove): void +{ + if (is_dir($path) && !is_link($path)) + { + foreach (new DirectoryIterator($path) as $entry) + { + if (!$entry->isDot()) + { + $remove($entry->getPathname()); + } + } + rmdir($path); + } + elseif (file_exists($path) || is_link($path)) + { + unlink($path); + } +}; + +try +{ + $write('api/src/Controller/RecordsController.php', <<<'PHP' +loadForm('com_fixture.record', 'record', ['load_data' => $loadData]); + $form->setValue('guid', null, GuidHelper::get()); + return $form; + } + public function getItem($pk = null) + { + $item = parent::getItem($pk); + $rows = new Registry; + $rows->loadString($item->rows); + $item->rows = $rows->toArray(); + return $item; + } + public function validate($form, $data, $group = null) + { + foreach (explode(',', $data['not_required']) as $requiredField) + { + $form->setFieldAttribute($requiredField, 'required', 'false'); + unset($data[$requiredField]); + } + return parent::validate($form, $data, $group); + } +} +PHP); + $write('admin/src/Field/ReferenceField.php', <<<'PHP' +items as $item) + { + $options[] = Html::_('select.option', $item->guid, $item->name); + } + return $options; + } +} +PHP); + $write('admin/src/Rule/GuidRule.php', ' +
+
+ + + + + + + + + + + + + + + + + +
+ + +XML; + $write('admin/forms/record.xml', $form); + $write('admin/forms/nested.xml', '
'); + $contracts = new FormContracts($temporary . '/admin', $temporary . '/api'); + $route = ['controller' => 'records.add', 'method' => 'POST']; + $contract = $contracts->forRoute($route); + $check($contract !== null && $contract['provenance']['model'] === 'record', 'Literal native model mapping resolves without singular-name guessing.'); + $check($contract['fields']['reference']['relationship']['option_value_properties'] === ['guid'], 'Relationship GUID values come from the installed field source.'); + $check($contract['fields']['modal_reference']['native_type'] === 'ModalSelect' + && $contract['fields']['modal_reference']['relationship']['option_value_properties'] === ['guid'] + && $contract['fields']['modal_reference']['field_source']['path'] === 'administrator/src/Field/ModalSelectField.php', + 'Native modal relationship keys and case-sensitive custom field source are preserved from XML.'); + $check($contract['fields']['guid']['identity']['server_generated'] && $contract['generation'] === [], 'Observed native GUID generation remains native.'); + $check($contract['native_required_adjustments'] && !isset($contract['schema']['required']), 'Native required adjustments prevent over-requiring a create body.'); + $check($contract['fields']['conditional']['showon'] === 'active:1' && $contract['fields']['conditional']['required'], 'Conditional declarations remain visible without invented enforcement.'); + $check($contract['fields']['active']['default'] === '0' && count($contract['fields']['active']['choices']) === 2, 'Literal defaults and choices retain their XML values.'); + $check($contract['fields']['source']['inert_source'] && $contract['schema']['properties']['source']['type'] === ['string', 'null'], 'Source code remains inert, unrestricted text.'); + $check(!isset($contract['fields']['note']), 'Presentation-only notes do not become write fields.'); + $check(in_array('not_required', $contract['verification']['request_only'], true) && in_array('ignored', $contract['verification']['request_only'], true), 'Only observed native controls and unset filters are request-only.'); + $check($contract['verification']['fields']['rows']['representation'] === 'json', 'Native Registry decoding supplies the structured read-back representation.'); + $check($contract['verification']['fields']['rows']['additionalProperties']['properties']['sequence']['representation'] === 'integer', 'Named repeatable rows retain native integer child contracts.'); + $check(isset($contract['schema']['properties']['rows']['anyOf'][0]['items']['properties']['details']['properties']['code']), 'Referenced and inline nested subforms expose their data structure.'); + $check($contract['verification']['fields']['settings']['properties']['enabled']['representation'] === 'boolean', 'Named native groups retain nested field contracts.'); + $check($contracts->forRoute($route)['fingerprint'] === $contract['fingerprint'], 'Contract fingerprints are deterministic.'); + (new SchemaValidator())->document(Json::encode($contract['schema'])); + $check(true, 'Generated data schema is a valid JSON Schema document.'); + $patchContract = $contracts->forRoute(['controller' => 'records.edit', 'method' => 'PATCH']); + $patchSchema = ['type' => 'object', 'properties' => ['data' => $patchContract['schema']], 'required' => ['data'], 'additionalProperties' => false]; + $patch = (new SchemaValidator())->input(['data' => ['title' => 'Changed title']], Json::encode($patchSchema)); + $check($patch['data'] === ['title' => 'Changed title'], 'Actual validation keeps omitted XML defaults and hidden controls absent from PATCH input.'); + $partialRows = ['data' => (object) ['rows' => [(object) ['value' => 'Retained value', + 'details' => (object) ['code' => ' (object) []]]; + $nestedPatch = (new SchemaValidator())->input($partialRows, Json::encode($patchSchema), true); + $check(Json::canonical($nestedPatch) === Json::canonical($partialRows), 'Actual validation preserves nested partial rows and empty named groups without inserting XML child defaults.'); + + // Comment and string contents can never manufacture native form mappings. + $write('api/src/Controller/UnboundController.php', <<<'PHP' +forRoute(['controller' => 'unbound.add', 'method' => 'POST']) === null, 'Embedded source and comments never bind a form.'); + $write('api/src/Controller/Site_recordController.php', <<<'PHP' +forRoute(['controller' => 'site_record.add', 'method' => 'POST']) === null, 'A Site model cannot accidentally inherit the matching administrator form.'); + $check($contracts->forRoute(['controller' => '../records.add', 'method' => 'POST']) === null, 'Controller names cannot select an arbitrary filesystem path.'); + $write('admin/forms/record.xml', ']>
'); + $reject(static fn () => $contracts->forRoute($route), 'JCB_FORM_CONTRACT_INVALID'); + $write('admin/forms/record.xml', '
'); + $reject(static fn () => $contracts->forRoute($route), 'JCB_FORM_CONTRACT_INVALID'); + $write('admin/forms/record.xml', '
'); + $reject(static fn () => $contracts->forRoute($route), 'JCB_FORM_CONTRACT_INVALID'); + $write('admin/forms/record.xml', '
'); + $write('outside.xml', '
'); + symlink($temporary . '/outside.xml', $temporary . '/admin/forms/outside.xml'); + $reject(static fn () => $contracts->forRoute($route), 'JCB_FORM_CONTRACT_INVALID'); + + // A static form gets create requirements; PATCH remains partial by contract. + $write('admin/src/Model/RecordModel.php', <<<'PHP' +loadForm('com_fixture.record', 'record', []); + } +} +PHP); + $write('admin/forms/record.xml', '
'); + $static = $contracts->forRoute($route); + $check($static['schema']['required'] === ['title', 'guid', 'relation_guid'], 'Only static unconditional requirements enter the create schema.'); + $check($static['generation']['guid'] === ['kind' => 'guid', 'on' => 'create', 'format' => 'uuid-v4'], 'A required client GUID without a native default has an explicit generation contract.'); + $check(!isset($static['generation']['relation_guid']) && !isset($static['fields']['relation_guid']['identity']), 'Required relationship GUIDs must be supplied from existing records and are never generated.'); + $check(!isset($contracts->forRoute(['controller' => 'records.edit', 'method' => 'PATCH'])['schema']['required']), 'Partial update data does not require every create field.'); + + // An optional argument inspects every controller of a real compiled package. + $installedRoot = $argv[1] ?? null; + + if ($installedRoot !== null) + { + $installed = new FormContracts($installedRoot . '/admin', $installedRoot . '/api'); + $observed = 0; + + foreach (glob($installedRoot . '/api/src/Controller/*Controller.php') as $path) + { + $name = substr(basename($path), 0, -strlen('Controller.php')); + $native = $installed->forRoute(['controller' => lcfirst($name) . '.add', 'method' => 'POST']); + + if ($native !== null) + { + (new SchemaValidator())->document(Json::encode($native['schema'])); + $observed++; + } + } + + $check($observed > 0, 'The supplied installed package exposes source-backed native form contracts.'); + echo 'Observed installed form contracts: ' . $observed . PHP_EOL; + } + + echo 'PASS ' . $checks . ' installed native form contract checks.' . PHP_EOL; +} +finally +{ + $remove($temporary); +} diff --git a/tests/schema-shapes.php b/tests/schema-shapes.php index 5bb71b8..d6d5467 100644 --- a/tests/schema-shapes.php +++ b/tests/schema-shapes.php @@ -7,6 +7,7 @@ * @license GNU General Public License version 3 or later; see LICENSE */ +use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; use VDM\Component\JoomEngineMcp\Administrator\Security\SchemaValidator; use VDM\Component\JoomEngineMcp\Administrator\Service\Json; @@ -54,4 +55,83 @@ { throw new RuntimeException('Generic action planning lost arbitrary JSON-valued native arguments.'); } -echo Json::encode(['checks' => $checks + 1, 'sourceSchemaObjects' => 'preserved except declared runtime extensions', 'nestedWriteArguments' => 'passed']) . PHP_EOL; +$checks++; +$schemas = new SchemaValidator(); +$readInput = (object) [ + 'filter' => (object) ['search' => 'literal & nested=value', 'state' => 0, 'active' => true], + 'payload' => (object) ['values' => [(object) [], [], 1, 1.5, true, null, 'text'], + 'numbered' => (object) ['0' => (object) [], '1' => (object) []]], +]; +$deep = 'leaf'; + +for ($depth = 0; $depth < 13; $depth++) +{ + $deep = (object) ['nested' => $deep]; +} + +foreach (['joomla_action_read', 'joomla_companion_action_read'] as $name) +{ + $document = $documents[$tools[$name]['input_schema_id']]; + $validated = $schemas->input(['action' => 'fixture.read', 'input' => $readInput], $document, true); + + if (Json::canonical($validated['input']) !== Json::canonical($readInput)) + { + throw new RuntimeException($name . ' changed nested read JSON objects, arrays or scalar values.'); + } + $checks++; + $default = $schemas->input(['action' => 'fixture.read'], $document, true); + + if (!$default['input'] instanceof stdClass) + { + throw new RuntimeException($name . ' changed its omitted input object default.'); + } + $checks++; + $flat = ['id' => 7, 'offset' => 0, 'limit' => 20, 'search' => 'Joomla', 'published' => true, 'optional' => null]; + + if ($schemas->input(['action' => 'fixture.read', 'input' => $flat], $document)['input'] !== $flat) + { + throw new RuntimeException($name . ' changed existing scalar read arguments.'); + } + $checks++; + + foreach ([[], null, 'scalar', true, 7, + (object) ['payload' => $deep], + (object) array_fill_keys(range(0, 512), 'value'), + (object) ['payload' => array_fill(0, 10001, 'value')], + (object) ['payload' => (object) ['__proto__' => 'value']], + (object) ['payload' => INF], + (object) ['payload' => new DateTimeImmutable()], + ] as $invalid) + { + try + { + $schemas->input(['action' => 'fixture.read', 'input' => $invalid], $document, true); + throw new RuntimeException($name . ' accepted an invalid JSON shape or exceeded shared input bounds.'); + } + catch (OperationException $error) + { + if ($error->getIdentifier() !== 'INVALID_INPUT') + { + throw $error; + } + $checks++; + } + } + + try + { + $schemas->input(['action' => 'fixture.read', 'input' => (object) ['payload' => str_repeat('x', 1048576)]], $document, true); + throw new RuntimeException($name . ' exceeded the existing input byte limit.'); + } + catch (OperationException $error) + { + if ($error->getIdentifier() !== 'RESULT_TOO_LARGE') + { + throw $error; + } + $checks++; + } +} + +echo Json::encode(['checks' => $checks, 'sourceSchemaObjects' => 'preserved except declared runtime extensions', + 'nestedWriteArguments' => 'passed', 'nestedReadArguments' => 'passed', 'readInputBounds' => 'passed']) . PHP_EOL; diff --git a/tests/schema-upgrade.php b/tests/schema-upgrade.php new file mode 100644 index 0000000..5a76b77 --- /dev/null +++ b/tests/schema-upgrade.php @@ -0,0 +1,312 @@ + + * @copyright Copyright (C) 2026 Vast Development Method. All rights reserved. + * @license GNU General Public License version 3 or later; see LICENSE + */ + +use Nyholm\Psr7\Response; +use Psr\Http\Client\ClientInterface; +use Psr\Http\Message\RequestInterface; +use Psr\Http\Message\ResponseInterface; +use VDM\Component\JoomEngineMcp\Administrator\Contract\HandlerInterface; +use VDM\Component\JoomEngineMcp\Administrator\Contract\PrincipalInterface; +use VDM\Component\JoomEngineMcp\Administrator\Domain\OperationException; +use VDM\Component\JoomEngineMcp\Administrator\Handler\ApiHandler; +use VDM\Component\JoomEngineMcp\Administrator\Handler\ApiRequestBuilder; +use VDM\Component\JoomEngineMcp\Administrator\Installer\SeedUpdater; +use VDM\Component\JoomEngineMcp\Administrator\Protocol\ToolDispatcher; +use VDM\Component\JoomEngineMcp\Administrator\Security\Authorizer; +use VDM\Component\JoomEngineMcp\Administrator\Security\Envelope; +use VDM\Component\JoomEngineMcp\Administrator\Security\SchemaValidator; +use VDM\Component\JoomEngineMcp\Administrator\Service\ActionExecutor; +use VDM\Component\JoomEngineMcp\Administrator\Service\Catalogue; +use VDM\Component\JoomEngineMcp\Administrator\Service\HandlerRegistry; +use VDM\Component\JoomEngineMcp\Administrator\Service\Json; +use VDM\Component\JoomEngineMcp\Administrator\Service\Settings; +use VDM\Component\JoomEngineMcp\Administrator\State\Audit; +use VDM\Component\JoomEngineMcp\Administrator\State\Executions; +use VDM\Component\JoomEngineMcp\Administrator\State\Permissions; +use VDM\Component\JoomEngineMcp\Tests\Support\MemoryStore; +use VDM\Component\JoomEngineMcp\Tests\Support\Principal; + + +$root = dirname(__DIR__); +require_once $root . '/admin/autoload.php'; +require_once __DIR__ . '/Support/MemoryStore.php'; +require_once __DIR__ . '/Support/Principal.php'; +set_error_handler(static function (int $severity, string $message, string $file, int $line): never +{ + throw new ErrorException($message, 0, $severity, $file, $line); +}); +$checks = 0; +$check = static function (bool $condition, string $message) use (&$checks): void +{ + $checks++; + + if (!$condition) + { + throw new RuntimeException($message); + } +}; +$reject = static function (callable $operation, string $identifier) use ($check): void +{ + try + { + $operation(); + } + catch (OperationException $error) + { + $check($error->getIdentifier() === $identifier, 'Expected ' . $identifier . ', got ' . $error->getIdentifier()); + return; + } + + throw new RuntimeException('An upgraded schema policy accepted an input that it previously denied.'); +}; +$seed = Json::decode(file_get_contents($root . '/admin/data/catalogue-seed.json'), maximum: 16777216); +$source = json_decode(file_get_contents($root . '/data/upstream-contracts.json'), false, 128, JSON_THROW_ON_ERROR); +$originalTools = array_column($source->tools, null, 'name'); +$legacy = $seed; +$replacementIds = []; + +// Reconstruct the actual previous read-tool relationships from the immutable +// imported contracts, without depending on another checkout or test-generated +// definitions. The original content-addressed schema rows remain in the seed. +foreach ($legacy['entities']['tool'] as &$tool) +{ + if (!in_array($tool['name'], ['joomla_action_read', 'joomla_companion_action_read'], true)) + { + continue; + } + + $replacementIds[(int) $tool['input_schema_id']] = true; + $original = Json::canonical($originalTools[$tool['name']]->inputSchema); + + foreach ($legacy['entities']['schema'] as $schema) + { + if (Json::canonical(json_decode($schema['document'], false, 128, JSON_THROW_ON_ERROR)) === $original) + { + $tool['input_schema_id'] = $schema['id']; + $tool['seed_revision'] = $legacy['source']; + break; + } + } + + $check((int) $tool['input_schema_id'] !== array_key_last($replacementIds), 'The actual old read schema was not found.'); +} +unset($tool); +$legacy['entities']['schema'] = array_values(array_filter($legacy['entities']['schema'], + static fn (array $schema): bool => !isset($replacementIds[(int) $schema['id']]))); + +/** Compose real dispatch, schema validation and read transport over test-owned storage. */ +$fixture = static function (MemoryStore $store, string $track = 'api'): array +{ + $http = new class implements ClientInterface + { + /** @var array Captured read requests. */ + public array $requests = []; + /** @inheritDoc */ + public function sendRequest(RequestInterface $request): ResponseInterface + { + if ($request->getMethod() !== 'GET') + { + throw new RuntimeException('Schema upgrade verification must never mutate Joomla.'); + } + + $this->requests[] = $request; + $id = (int) basename($request->getUri()->getPath()); + return new Response(200, [], Json::encode(['data' => ['id' => (string) $id, + 'attributes' => ['id' => $id, 'title' => 'Read through the approved schema']]])); + } + }; + $native = new class implements HandlerInterface + { + /** @var int Captured local read operations. */ + public int $reads = 0; + /** @inheritDoc */ + public function execute(array $arguments, array $binding, PrincipalInterface $principal): array + { + if (($binding['configuration']['operation'] ?? '') !== 'get') + { + throw new RuntimeException('The upgrade fixture accepts only native reads.'); + } + + $this->reads++; + return ['entity' => 'content.articles', 'item' => ['id' => $arguments['id']]]; + } + }; + $principal = new Principal('joomla:17', $track, [1]); + $settings = new Settings(['api_base' => 'https://joomla.example/api/index.php']); + $schemas = new SchemaValidator(); + $catalogue = new Catalogue($store, new Authorizer(), $principal, $schemas, $settings, + static fn (string $extension): bool => true, static fn (string $entity, string $handler): bool => true); + $clock = static fn (): int => 1900000000; + $audit = new Audit($store, $principal, $clock); + $permissions = new Permissions($store, $principal, $catalogue, $settings, $audit, $clock); + $state = new Executions($store, $principal, new Envelope(str_repeat('s', 32)), $permissions, $settings, $audit, $clock); + $builder = new ApiRequestBuilder(); + $api = new ApiHandler($http, $builder, $settings, 'test-token', static fn (): string => 'unused'); + $executor = new ActionExecutor($catalogue, $schemas, $principal, + new HandlerRegistry(['api.request' => $api, 'native.core-entity' => $native]), $permissions, $state, $audit, $settings, $builder); + $dispatcher = new ToolDispatcher($catalogue, $executor, $permissions, $schemas, $principal, $settings); + return compact('http', 'native', 'catalogue', 'dispatcher'); +}; +$io = static fn (array $fixture): int => count($fixture['http']->requests) + $fixture['native']->reads; +$allowed = ['action' => 'content.articles.get', 'input' => ['id' => 7]]; +$denied = ['action' => 'content.articles.get', 'input' => ['id' => 8]]; + +foreach (['joomla_action_read' => 'api', 'joomla_companion_action_read' => 'cli'] as $name => $track) +{ + foreach ([false, true] as $flagged) + { + $store = new MemoryStore(); + $updater = new SeedUpdater($store); + $updater->apply($legacy); + $tool = $store->one('tool', ['name' => $name]); + $schema = $store->one('schema', ['id' => $tool['input_schema_id']]); + $document = json_decode($schema['document'], false, 128, JSON_THROW_ON_ERROR); + $document->properties->action->enum = ['content.articles.get']; + $document->properties->input->properties = (object) ['id' => (object) ['type' => 'integer', 'maximum' => 7]]; + $store->update('schema', ['document' => Json::encode($document), 'customized' => (int) $flagged], ['id' => $schema['id']]); + $check(SeedUpdater::hash('tool', $tool) === $tool['seed_hash'] && (int) $tool['customized'] === 0, + 'The reproduction must customize only the shared schema, never its tool.'); + $f = $fixture($store, $track); + foreach (['before', 'after', 'repeat'] as $phase) + { + if ($phase !== 'before') + { + $counts = $updater->apply($seed); + $check($phase !== 'repeat' || $counts['updated'] === 0, 'An owned schema relationship is not idempotent on repeated upgrade.'); + } + + $before = $io($f); + $read = $f['dispatcher']->call($name, $allowed); + $check($read['transport'] === $track && $io($f) === $before + 1, 'An accepted core read stopped working ' . $phase . ' upgrade.'); + $reject(static fn () => $f['dispatcher']->call($name, $denied), 'INVALID_INPUT'); + $check($io($f) === $before + 1, 'A denied schema input reached read transport ' . $phase . ' upgrade.'); + } + + $check($store->one('tool', ['name' => $name])['input_schema_id'] === $tool['input_schema_id'], + 'Preserving a schema row alone is insufficient: the effective tool relationship changed.'); + } + + // Disabled and missing schemas are both effective execution denials. + foreach (['disabled', 'missing'] as $unavailable) + { + $store = new MemoryStore(); + $updater = new SeedUpdater($store); + $updater->apply($legacy); + $tool = $store->one('tool', ['name' => $name]); + if ($unavailable === 'disabled') + { + $store->update('schema', ['published' => 0], ['id' => $tool['input_schema_id']]); + } + else + { + $store->remove('schema', ['id' => $tool['input_schema_id']]); + } + $f = $fixture($store, $track); + $reject(static fn () => $f['dispatcher']->call($name, $allowed), 'DEFINITION_UNAVAILABLE'); + $updater->apply($seed); + $reject(static fn () => $f['dispatcher']->call($name, $allowed), 'DEFINITION_UNAVAILABLE'); + $check($io($f) === 0, 'Upgrading bypassed an unavailable administrator input schema.'); + $check($updater->apply($seed)['updated'] === 0, 'Unavailable schema preservation is not idempotent.'); + } + + // Removal from the incoming graph must not retire an owned active schema + // before dependent tools are checked later in the same upgrade transaction. + $store = new MemoryStore(); + $updater = new SeedUpdater($store); + $updater->apply($legacy); + $tool = $store->one('tool', ['name' => $name]); + $schema = $store->one('schema', ['id' => $tool['input_schema_id']]); + $document = json_decode($schema['document'], false, 128, JSON_THROW_ON_ERROR); + $document->properties->input->properties = (object) ['id' => (object) ['type' => 'integer', 'maximum' => 7]]; + $store->update('schema', ['document' => Json::encode($document)], ['id' => $schema['id']]); + $withoutOriginal = $seed; + $withoutOriginal['entities']['schema'] = array_values(array_filter($withoutOriginal['entities']['schema'], + static fn (array $row): bool => $row['name'] !== $schema['name'])); + $f = $fixture($store, $track); + $reject(static fn () => $f['dispatcher']->call($name, $denied), 'INVALID_INPUT'); + $updater->apply($withoutOriginal); + $reject(static fn () => $f['dispatcher']->call($name, $denied), 'INVALID_INPUT'); + $check($f['dispatcher']->call($name, $allowed)['transport'] === $track && $io($f) === 1, + 'An omitted owned schema lost its accepted read or retired its policy.'); + $check($updater->apply($withoutOriginal)['updated'] === 0, 'An omitted owned schema relationship is not idempotent.'); + + // Untouched installations still receive the new default nested-read envelope. + $store = new MemoryStore(); + $updater = new SeedUpdater($store); + $updater->apply($legacy); + $old = $store->one('tool', ['name' => $name]); + $updater->apply($seed); + $new = $store->one('tool', ['name' => $name]); + $check($new['input_schema_id'] !== $old['input_schema_id'], 'A pristine tool did not adopt its new shipped read contract.'); + $f = $fixture($store, $track); + $check($f['dispatcher']->call($name, $allowed)['transport'] === $track && $io($f) === 1, + 'An untouched installation lost an accepted core read after upgrading.'); +} + +// A replacement execution-track schema must likewise retain its previous +// schema-only input restrictions and independent output validation policy. +foreach (['input_schema_id' => 'INVALID_INPUT', 'output_schema_id' => 'OUTPUT_CONTRACT_FAILED'] as $field => $error) +{ + foreach ([false, true] as $flagged) + { + $store = new MemoryStore(); + $updater = new SeedUpdater($store); + $updater->apply($seed); + $binding = $store->one('binding', ['name' => 'content.articles.get.api']); + $schema = $store->one('schema', ['id' => $binding[$field]]); + $document = json_decode($schema['document'], false, 128, JSON_THROW_ON_ERROR); + $restricted = clone $document; + if ($field === 'input_schema_id') + { + $restricted->properties = (object) ['id' => (object) ['type' => 'integer', 'minimum' => 1, 'maximum' => 7]]; + } + else + { + $restricted->properties = (object) ['data' => (object) ['type' => 'object', + 'properties' => (object) ['data' => (object) ['type' => 'object', + 'properties' => (object) ['id' => (object) ['const' => '7']]]]]]; + } + $store->update('schema', ['document' => Json::encode($restricted), 'customized' => (int) $flagged], ['id' => $schema['id']]); + $next = $seed; + $replacement = $schema; + $replacement['id'] = max(array_column($next['entities']['schema'], 'id')) + 1; + $document->description = 'Replacement shipped binding schema for upgrade contract testing.'; + $replacement['name'] = 'schema.' . hash('sha256', Json::canonical($document)); + $replacement['document'] = Json::encode($document); + $replacement['seed_revision'] = $next['runtimeSource']; + $next['entities']['schema'][] = $replacement; + foreach ($next['entities']['binding'] as &$row) + { + if ($row['name'] === $binding['name']) + { + $row[$field] = $replacement['id']; + } + } + unset($row); + $f = $fixture($store); + foreach (['before', 'after', 'repeat'] as $phase) + { + if ($phase !== 'before') + { + $counts = $updater->apply($next); + $check($phase !== 'repeat' || $counts['updated'] === 0, 'An owned binding schema reference is not idempotent.'); + } + $before = $io($f); + $check($f['dispatcher']->call('joomla_action_read', $allowed)['response']['status'] === 200, + 'An accepted input/output policy read stopped working ' . $phase . ' binding upgrade.'); + $reject(static fn () => $f['dispatcher']->call('joomla_action_read', ['action' => 'content.articles.get', 'input' => ['id' => 8]]), $error); + $check($io($f) === $before + ($field === 'input_schema_id' ? 1 : 2), + 'Binding policy validation occurred at the wrong transport boundary.'); + } + $check($store->one('binding', ['name' => $binding['name']])[$field] === $binding[$field], + 'The effective customized binding schema reference was replaced.'); + } +} + +echo 'Schema-only upgrade policy contract checks: ' . $checks . PHP_EOL; diff --git a/tests/wire-protocol.php b/tests/wire-protocol.php index 22dece5..6ce992a 100644 --- a/tests/wire-protocol.php +++ b/tests/wire-protocol.php @@ -229,16 +229,70 @@ public function execute(array $arguments, array $binding, PrincipalInterface $pr unlink($outputPath); } -// A current action contract can accept object-valued input. Preserve it through -// the generic tool's validation, action validation and handler invocation. +// A current action contract can accept object-valued input. The shipped generic +// read tool schemas must pass it to action validation without a fixture override. $binding = $store->one('binding', ['name' => 'system.info.cli']); $actionSchema = ['type' => 'object', 'properties' => ['payload' => ['type' => 'object', - 'additionalProperties' => ['type' => 'object']]], 'required' => ['payload'], 'additionalProperties' => false]; + 'additionalProperties' => ['type' => 'object']], 'filter' => ['type' => 'object', + 'properties' => ['search' => ['type' => 'string', 'maxLength' => 2048], 'state' => ['type' => 'integer'], + 'active' => ['type' => 'boolean']], 'maxProperties' => 32, 'additionalProperties' => false]], + 'anyOf' => [['required' => ['payload']], ['required' => ['filter']]], 'additionalProperties' => false]; $store->update('schema', ['document' => Json::encode($actionSchema)], ['id' => $binding['input_schema_id']]); -$readTool = $store->one('tool', ['name' => 'joomla_action_read']); -$readSchema = Json::decode($catalogue->schema((int) $readTool['input_schema_id']), false); -$readSchema->properties->input = (object) $actionSchema; -$store->update('schema', ['document' => Json::encode($readSchema)], ['id' => $readTool['input_schema_id']]); +$nestedMessages = [$messages[0], $messages[1]]; +$nestedIds = []; + +foreach (['joomla_action_read', 'joomla_companion_action_read'] as $name) +{ + $id = 'stdio-nested-filter-' . $name; + $nestedIds[] = $id; + $nestedMessages[] = ['jsonrpc' => '2.0', 'id' => $id, 'method' => 'tools/call', 'params' => ['name' => $name, + 'arguments' => ['action' => 'system.info', 'input' => (object) ['filter' => (object) ['search' => 'nested & literal=value', 'state' => 0]]]]]; +} +$nestedInput = fopen('php://temp', 'w+'); +$nestedOutputPath = tempnam(sys_get_temp_dir(), 'mcp-wire-nested-output-'); +$nestedOutput = fopen($nestedOutputPath, 'w+'); +$beforeNestedCalls = $native->calls; + +try +{ + foreach ($nestedMessages as $message) + { + fwrite($nestedInput, Json::encode($message) . "\n"); + } + rewind($nestedInput); + $check($server->run(new StdioTransport($nestedInput, $nestedOutput, wire: $servers->wireInput())) === 0, + 'The actual newline/stdin transport accepts nested read inputs cleanly.'); + $nestedReplies = []; + + foreach (explode("\n", trim(file_get_contents($nestedOutputPath))) as $line) + { + $message = Json::decode($line); + $nestedReplies[$message['id']] = $message; + } + + foreach ($nestedIds as $id) + { + $check(($nestedReplies[$id]['result']['isError'] ?? false) === false + && isset($nestedReplies[$id]['result']['structuredContent']['response']), + 'Both shipped generic read envelopes accept nested filters over the actual newline/stdin transport.'); + } + $check($native->calls === $beforeNestedCalls + 2 + && $native->arguments === ['filter' => ['search' => 'nested & literal=value', 'state' => 0]], + 'Newline/stdin nested filters reach the selected native handler once per valid invocation without coercion.'); +} +finally +{ + if (is_resource($nestedInput)) + { + fclose($nestedInput); + } + + if (is_resource($nestedOutput)) + { + fclose($nestedOutput); + } + unlink($nestedOutputPath); +} // The object/list distinction must also survive nested object schemas, lists, // additionalProperties, numeric keys and default values on both HTTP eras. @@ -288,11 +342,32 @@ public function execute(array $arguments, array $binding, PrincipalInterface $pr foreach ([false, true] as $modern) { - $reply = $http(['jsonrpc' => '2.0', 'id' => 'nested-action-map', 'method' => 'tools/call', 'params' => ['name' => 'joomla_action_read', - 'arguments' => ['action' => 'system.info', 'input' => (object) ['payload' => (object) ['0' => (object) [], '1' => (object) []]]]]], $modern); - $check(($reply['result']['isError'] ?? false) === false && isset($reply['result']['structuredContent']['response']) - && Json::encode($native->arguments['payload'] ?? null) === '{"0":{},"1":{}}', - 'Numeric-only object maps survive generic tool validation, action validation and native invocation in both HTTP eras.'); + foreach (['joomla_action_read', 'joomla_companion_action_read'] as $name) + { + $reply = $http(['jsonrpc' => '2.0', 'id' => 'nested-action-map', 'method' => 'tools/call', 'params' => ['name' => $name, + 'arguments' => ['action' => 'system.info', 'input' => (object) ['payload' => (object) ['0' => (object) [], '1' => (object) []]]]]], $modern); + $check(($reply['result']['isError'] ?? false) === false && isset($reply['result']['structuredContent']['response']) + && Json::encode($native->arguments['payload'] ?? null) === '{"0":{},"1":{}}', + 'Numeric-only object maps survive both shipped read envelopes, action validation and native invocation in both HTTP eras.'); + $filter = (object) ['search' => 'literal & nested=value', 'state' => 0, 'active' => true]; + $reply = $http(['jsonrpc' => '2.0', 'id' => 'nested-action-filter', 'method' => 'tools/call', 'params' => ['name' => $name, + 'arguments' => ['action' => 'system.info', 'input' => (object) ['filter' => $filter]]]], $modern); + $check(($reply['result']['isError'] ?? false) === false && isset($reply['result']['structuredContent']['response']) + && Json::canonical($native->arguments['filter'] ?? null) === Json::canonical($filter), + 'Nested filter values survive both shipped read envelopes and retain scalar types through native invocation.'); + $beforeCalls = $native->calls; + + foreach ([(object) ['filter' => (object) ['search' => []]], (object) ['filter' => (object) ['state' => '0']], + (object) ['filter' => (object) ['unexpected' => 'value']], (object) ['unexpected' => (object) []]] as $invalid) + { + $reply = $http(['jsonrpc' => '2.0', 'id' => 'invalid-action-filter', 'method' => 'tools/call', 'params' => ['name' => $name, + 'arguments' => ['action' => 'system.info', 'input' => $invalid]]], $modern); + $check(($reply['result']['isError'] ?? false) === true + && Json::decode($reply['result']['content'][0]['text'])['error']['code'] === 'INVALID_INPUT' + && $native->calls === $beforeCalls, + 'Nested generic read acceptance cannot bypass the selected action schema or execute invalid native input.'); + } + } $reply = $http(['jsonrpc' => '2.0', 'id' => 401, 'method' => 'tools/call', 'params' => ['name' => 'joomla_sites_list', 'arguments' => $valid]], $modern); $check(($reply['result']['structuredContent']['sites'][0]['id'] ?? '') === 'default', 'HTTP validates nested empty JSON objects and numeric object keys in both eras.');