From a0bd401fe27fc8a0b33e2c2cc5b6df019708fb44 Mon Sep 17 00:00:00 2001 From: Ruben van der Linde Date: Tue, 26 May 2026 22:51:58 +0200 Subject: [PATCH] refactor(mcp): decompose OpenBuiltToolProvider handlers (cut complexity, no behavior change) --- lib/Mcp/Handler/AbstractToolHandler.php | 253 ++++++ lib/Mcp/Handler/AddWidgetHandler.php | 195 +++++ lib/Mcp/Handler/CreateAppHandler.php | 111 +++ lib/Mcp/Handler/GetAppManifestHandler.php | 117 +++ lib/Mcp/Handler/ListAppsHandler.php | 135 +++ lib/Mcp/Handler/PromoteVersionHandler.php | 134 +++ lib/Mcp/Handler/UpsertMenuItemHandler.php | 187 ++++ lib/Mcp/Handler/UpsertPageHandler.php | 194 +++++ lib/Mcp/Handler/UpsertSchemaHandler.php | 260 ++++++ lib/Mcp/OpenBuiltToolProvider.php | 982 +--------------------- 10 files changed, 1622 insertions(+), 946 deletions(-) create mode 100644 lib/Mcp/Handler/AbstractToolHandler.php create mode 100644 lib/Mcp/Handler/AddWidgetHandler.php create mode 100644 lib/Mcp/Handler/CreateAppHandler.php create mode 100644 lib/Mcp/Handler/GetAppManifestHandler.php create mode 100644 lib/Mcp/Handler/ListAppsHandler.php create mode 100644 lib/Mcp/Handler/PromoteVersionHandler.php create mode 100644 lib/Mcp/Handler/UpsertMenuItemHandler.php create mode 100644 lib/Mcp/Handler/UpsertPageHandler.php create mode 100644 lib/Mcp/Handler/UpsertSchemaHandler.php diff --git a/lib/Mcp/Handler/AbstractToolHandler.php b/lib/Mcp/Handler/AbstractToolHandler.php new file mode 100644 index 0000000..028b610 --- /dev/null +++ b/lib/Mcp/Handler/AbstractToolHandler.php @@ -0,0 +1,253 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Mcp\Handler; + +use OCP\IUserSession; +use Psr\Container\ContainerInterface; +use Psr\Log\LoggerInterface; + +/** + * Abstract base for all OpenBuilt MCP tool handler classes. + * + * Concrete handlers extend this and implement handle(array $args): array. + */ +abstract class AbstractToolHandler +{ + + protected const REGISTER_SLUG = 'openbuilt'; + + /** + * Constructor. + * + * @param IUserSession $userSession User session used to resolve the current authenticated user. + * @param ContainerInterface $container DI container used to resolve OpenRegister services lazily. + * @param LoggerInterface $logger PSR logger used for non-fatal warnings and error logging. + */ + public function __construct( + protected readonly IUserSession $userSession, + protected readonly ContainerInterface $container, + protected readonly LoggerInterface $logger, + ) { + }//end __construct() + + /** + * Execute the tool logic for the given arguments. + * + * @param array $args Raw MCP tool arguments. + * + * @return array + */ + abstract public function handle(array $args): array; + + /** + * Build a uniform MCP error envelope. + * + * @param string $error Machine-readable error code. + * @param string $message Human-readable, end-user-safe error message. + * + * @return array{isError: true, error: string, message: string} + */ + protected function errorResult(string $error, string $message): array + { + return ['isError' => true, 'error' => $error, 'message' => $message]; + + }//end errorResult() + + /** + * Return the authenticated user's UID, or null if there is no session user. + * + * @return string|null + */ + protected function requireAuthenticatedUser(): ?string + { + $user = $this->userSession->getUser(); + if ($user === null) { + return null; + } + + $uid = $user->getUID(); + if ($uid === '') { + return null; + } + + return $uid; + + }//end requireAuthenticatedUser() + + /** + * Validate that a candidate string matches the OpenBuilt slug shape. + * + * @param string $candidate Candidate slug to validate. + * + * @return bool + */ + protected function isValidSlug(string $candidate): bool + { + if (strlen($candidate) < 2 || strlen($candidate) > 48) { + return false; + } + + return (bool) preg_match('/^[a-z0-9][a-z0-9-]*[a-z0-9]$/', $candidate); + + }//end isValidSlug() + + /** + * Build a Nextcloud deep link into the OpenBuilt builder for the given application slug. + * + * @param string $slug Application slug (empty falls back to the app root). + * + * @return string + */ + protected function buildDeepLink(string $slug): string + { + if ($slug === '') { + return '/apps/openbuilt'; + } + + return "/apps/openbuilt/builder/{$slug}"; + + }//end buildDeepLink() + + /** + * Build an MCP "source" descriptor pointing at the OpenBuilt app deep link. + * + * @param string $uuid Application UUID. + * @param string $slug Application slug used to build the deep link. + * @param string $label Human-readable label for the source descriptor. + * + * @return array{type: string, uuid: string, url: string, label: string} + */ + protected function sourceDescriptor(string $uuid, string $slug, string $label): array + { + return ['type' => 'openbuilt.application', 'uuid' => $uuid, 'url' => $this->buildDeepLink(slug: $slug), 'label' => $label]; + + }//end sourceDescriptor() + + /** + * Coerce an OR entity, array, or generic value into an associative array. + * + * @param mixed $item Value to coerce. + * + * @return array + */ + protected function toArray(mixed $item): array + { + if (is_array($item) === true) { + return $item; + } + + if (is_object($item) === true && method_exists($item, 'jsonSerialize') === true) { + $serialised = $item->jsonSerialize(); + if (is_array($serialised) === true) { + return $serialised; + } + } + + return (array) $item; + + }//end toArray() + + /** + * Extract a UUID from a normalised OR object array. + * + * @param array $item Normalised OR object as an associative array. + * + * @return string + */ + protected function extractUuid(array $item): string + { + $uuid = $item['uuid'] ?? $item['id'] ?? ($item['@self']['uuid'] ?? ($item['@self']['id'] ?? '')); + return (string) $uuid; + + }//end extractUuid() + + /** + * Resolve to {version, appUuid, appName}, or {error, message}. + * + * @param object $objectService OpenRegister ObjectService instance. + * @param string $appSlug Application slug to resolve. + * @param string $versionSlug ApplicationVersion slug to resolve. + * + * @return array{version?: array, appUuid?: string, appName?: string, error?: string, message?: string} + */ + protected function loadVersion(object $objectService, string $appSlug, string $versionSlug): array + { + $apps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', ['slug' => $appSlug]); + if (is_array($apps) === false || $apps === []) { + return ['error' => 'not_found', 'message' => "No virtual app found for slug '{$appSlug}'."]; + } + + $app = $this->toArray(item: $apps[0]); + $appUuid = $this->extractUuid(item: $app); + + $versions = $objectService->searchObjectsBySlug( + self::REGISTER_SLUG, + 'applicationVersion', + ['application' => $appUuid, 'slug' => $versionSlug] + ); + if (is_array($versions) === false || $versions === []) { + return ['error' => 'not_found', 'message' => "No version '{$versionSlug}' found for app '{$appSlug}'."]; + } + + return [ + 'version' => $this->toArray(item: $versions[0]), + 'appUuid' => $appUuid, + 'appName' => (string) ($app['name'] ?? $appSlug), + ]; + + }//end loadVersion() + + /** + * Save an ApplicationVersion with a new manifest. + * + * @param object $objectService OpenRegister ObjectService instance. + * @param array $version The existing ApplicationVersion as an associative array. + * @param array $manifest The new manifest blob to write onto the version. + * + * @return array + */ + protected function saveVersionManifest(object $objectService, array $version, array $manifest): array + { + $versionUuid = $this->extractUuid(item: $version); + $payload = $version; + $payload['manifest'] = $manifest; + + // Drop OR-internal `@self` / metadata keys that some readers tack on so + // saveObject treats the input as a clean property bag. + unset($payload['@self'], $payload['id'], $payload['uuid']); + + $saved = $objectService->saveObject( + object: $payload, + register: self::REGISTER_SLUG, + schema: 'applicationVersion', + uuid: $versionUuid, + ); + + return $this->toArray(item: $saved); + + }//end saveVersionManifest() +}//end class diff --git a/lib/Mcp/Handler/AddWidgetHandler.php b/lib/Mcp/Handler/AddWidgetHandler.php new file mode 100644 index 0000000..b67da04 --- /dev/null +++ b/lib/Mcp/Handler/AddWidgetHandler.php @@ -0,0 +1,195 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Mcp\Handler; + +/** + * Handles the openbuilt.addWidget tool invocation. + */ +class AddWidgetHandler extends AbstractToolHandler +{ + /** + * Execute the addWidget tool. + * + * @param array $args Tool arguments (appSlug, versionSlug, pageId, widgetType, widgetConfig). + * + * @return array + */ + public function handle(array $args): array + { + $validation = $this->validateArgs(args: $args); + if (isset($validation['error']) === true) { + return $this->errorResult(error: 'invalid_arguments', message: $validation['error']); + } + + if ($this->requireAuthenticatedUser() === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in to add widgets.'); + } + + $appSlug = $validation['appSlug']; + $versionSlug = $validation['versionSlug']; + $pageId = $validation['pageId']; + $widgetType = $validation['widgetType']; + $widgetConfig = $validation['widgetConfig']; + + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + + $loaded = $this->loadVersion(objectService: $objectService, appSlug: $appSlug, versionSlug: $versionSlug); + if (isset($loaded['error']) === true) { + return $this->errorResult(error: $loaded['error'], message: $loaded['message']); + } + + $version = $loaded['version']; + $manifest = (array) ($version['manifest'] ?? []); + $pages = (array) ($manifest['pages'] ?? []); + + $foundIdx = $this->findPageIndex(pages: $pages, pageId: $pageId); + if ($foundIdx === null) { + return $this->errorResult(error: 'not_found', message: "Page '{$pageId}' not found in manifest."); + } + + [$pages, $widget] = $this->appendWidget( + pages: $pages, + pageIdx: $foundIdx, + widgetType: $widgetType, + widgetConfig: $widgetConfig + ); + + $manifest['pages'] = array_values($pages); + $saved = $this->saveVersionManifest(objectService: $objectService, version: $version, manifest: $manifest); + + $pageConfig = (array) ($pages[$foundIdx]['config'] ?? []); + $widgetCount = count((array) ($pageConfig['widgets'] ?? [])); + + return [ + 'success' => true, + 'added' => true, + 'widget' => $widget, + 'pageId' => $pageId, + 'widgetCount' => $widgetCount, + 'version' => [ + 'uuid' => $this->extractUuid(item: $saved), + 'slug' => (string) ($saved['slug'] ?? $versionSlug), + ], + ]; + } catch (\Throwable $e) { + $this->logger->error( + 'OpenBuilt MCP: addWidget failed', + ['appSlug' => $appSlug, 'pageId' => $pageId, 'exception' => $e->getMessage()] + ); + return $this->errorResult(error: 'add_failed', message: 'Failed to add widget: '.$e->getMessage()); + }//end try + + }//end handle() + + /** + * Validate and extract typed arguments for addWidget. + * + * @param array $args Raw tool arguments. + * + * @return array{appSlug?: string, versionSlug?: string, pageId?: string, widgetType?: string, widgetConfig?: array, error?: string} + */ + private function validateArgs(array $args): array + { + $appSlug = (string) ($args['appSlug'] ?? ''); + $versionSlug = (string) ($args['versionSlug'] ?? 'development'); + $pageId = (string) ($args['pageId'] ?? ''); + $widgetType = (string) ($args['widgetType'] ?? ''); + $widgetConfig = $args['widgetConfig'] ?? []; + + if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { + return ['error' => "Invalid appSlug '{$appSlug}'."]; + } + + if ($pageId === '') { + return ['error' => 'pageId is required.']; + } + + if ($widgetType === '') { + return ['error' => 'widgetType is required.']; + } + + if (is_array($widgetConfig) === false) { + $widgetConfig = []; + } + + return [ + 'appSlug' => $appSlug, + 'versionSlug' => $versionSlug, + 'pageId' => $pageId, + 'widgetType' => $widgetType, + 'widgetConfig' => $widgetConfig, + ]; + + }//end validateArgs() + + /** + * Find the array index of a page by case-insensitive id matching. + * + * @param array $pages Pages list from the manifest. + * @param string $pageId The page id to find. + * + * @return int|null Index of the found page, or null if not found. + */ + private function findPageIndex(array $pages, string $pageId): ?int + { + $pageIdLc = strtolower($pageId); + foreach ($pages as $i => $existing) { + if (is_array($existing) === true && strtolower((string) ($existing['id'] ?? '')) === $pageIdLc) { + return $i; + } + } + + return null; + + }//end findPageIndex() + + /** + * Append a widget to the target page's config.widgets array. + * + * @param array $pages Pages list from the manifest. + * @param int $pageIdx Index of the target page. + * @param string $widgetType Widget type identifier. + * @param array $widgetConfig Widget-type-specific configuration blob. + * + * @return array{0: array, 1: array{type: string, config: array}} + */ + private function appendWidget(array $pages, int $pageIdx, string $widgetType, array $widgetConfig): array + { + $page = $pages[$pageIdx]; + $pageConfig = (array) ($page['config'] ?? []); + $widgets = (array) ($pageConfig['widgets'] ?? []); + $widget = ['type' => $widgetType, 'config' => $widgetConfig]; + $widgets[] = $widget; + $pageConfig['widgets'] = $widgets; + $page['config'] = $pageConfig; + $pages[$pageIdx] = $page; + + return [$pages, $widget]; + + }//end appendWidget() +}//end class diff --git a/lib/Mcp/Handler/CreateAppHandler.php b/lib/Mcp/Handler/CreateAppHandler.php new file mode 100644 index 0000000..8914028 --- /dev/null +++ b/lib/Mcp/Handler/CreateAppHandler.php @@ -0,0 +1,111 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Mcp\Handler; + +/** + * Handles the openbuilt.createApp tool invocation. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-8 + */ +class CreateAppHandler extends AbstractToolHandler +{ + + private const CREATE_PRESETS = ['single', 'dev-prod', 'dev-staging-prod']; + + /** + * Execute the createApp tool. + * + * @param array $args Tool arguments (slug, name, description, preset). + * + * @return array + */ + public function handle(array $args): array + { + $slug = (string) ($args['slug'] ?? ''); + $name = (string) ($args['name'] ?? ''); + $description = (string) ($args['description'] ?? ''); + $preset = (string) ($args['preset'] ?? 'dev-prod'); + + $argError = $this->validateArgs(slug: $slug, name: $name, preset: $preset); + if ($argError !== null) { + return $this->errorResult(error: 'invalid_arguments', message: $argError); + } + + if ($this->requireAuthenticatedUser() === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in to create a virtual app.'); + } + + try { + $creationService = $this->container->get('OCA\OpenBuilt\Service\ApplicationCreationService'); + $appUuid = $creationService->createApplication( + [ + 'slug' => $slug, + 'name' => $name, + 'description' => $description, + 'preset' => $preset, + ] + ); + + return [ + 'success' => true, + 'created' => true, + 'app' => ['uuid' => $appUuid, 'slug' => $slug, 'name' => $name, 'preset' => $preset], + 'sources' => [$this->sourceDescriptor(uuid: $appUuid, slug: $slug, label: $name)], + ]; + } catch (\Throwable $e) { + $this->logger->error('OpenBuilt MCP: createApp failed', ['slug' => $slug, 'exception' => $e->getMessage()]); + return $this->errorResult(error: 'create_failed', message: 'Failed to create virtual app: '.$e->getMessage()); + }//end try + + }//end handle() + + /** + * Validate the arguments for createApp, returning an error string or null on success. + * + * @param string $slug Application slug. + * @param string $name Application display name. + * @param string $preset Version chain preset. + * + * @return string|null + */ + private function validateArgs(string $slug, string $name, string $preset): ?string + { + if ($slug === '' || $this->isValidSlug(candidate: $slug) === false) { + return "Invalid slug '{$slug}'."; + } + + if ($name === '' || strlen($name) < 2 || strlen($name) > 80) { + return 'Name must be between 2 and 80 characters.'; + } + + if (in_array(needle: $preset, haystack: self::CREATE_PRESETS, strict: true) === false) { + return "Invalid preset '{$preset}'."; + } + + return null; + + }//end validateArgs() +}//end class diff --git a/lib/Mcp/Handler/GetAppManifestHandler.php b/lib/Mcp/Handler/GetAppManifestHandler.php new file mode 100644 index 0000000..82c0772 --- /dev/null +++ b/lib/Mcp/Handler/GetAppManifestHandler.php @@ -0,0 +1,117 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Mcp\Handler; + +/** + * Handles the openbuilt.getAppManifest tool invocation. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-50 + */ +class GetAppManifestHandler extends AbstractToolHandler +{ + /** + * Execute the getAppManifest tool. + * + * @param array $args Tool arguments (slug). + * + * @return array + */ + public function handle(array $args): array + { + $slug = $args['slug'] ?? null; + if ($slug === null || $slug === '') { + return $this->errorResult(error: 'invalid_arguments', message: 'Required argument slug is missing.'); + } + + if ($this->isValidSlug(candidate: (string) $slug) === false) { + return $this->errorResult(error: 'invalid_arguments', message: "Invalid slug '{$slug}'."); + } + + if ($this->requireAuthenticatedUser() === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in to read a virtual app manifest.'); + } + + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + $resolved = $this->resolveApplicationBySlug(objectService: $objectService, slug: (string) $slug); + if (isset($resolved['error']) === true) { + return $this->errorResult(error: $resolved['error'], message: $resolved['message']); + } + + $application = $resolved['application']; + $manifest = ($application['manifest'] ?? null); + if (is_array(value: $manifest) === false) { + return $this->errorResult(error: 'no_manifest', message: 'Application has no manifest.'); + } + + $name = (string) ($application['name'] ?? $slug); + return [ + 'success' => true, + 'slug' => (string) $slug, + 'name' => $name, + 'manifest' => $manifest, + 'sources' => [$this->sourceDescriptor(uuid: $this->extractUuid(item: $application), slug: (string) $slug, label: $name)], + ]; + } catch (\Throwable $e) { + $this->logger->error( + 'OpenBuilt MCP: getAppManifest failed', + ['slug' => $slug, 'exception' => $e->getMessage()] + ); + return $this->errorResult(error: 'internal_error', message: 'Failed to resolve manifest.'); + }//end try + + }//end handle() + + /** + * Resolve a published-app slug to its underlying Application object via the built-app-route schema. + * + * @param object $objectService OpenRegister ObjectService instance used for slug lookups. + * @param string $slug Public route slug of the published virtual app. + * + * @return array{application?: array, error?: string, message?: string} + */ + private function resolveApplicationBySlug(object $objectService, string $slug): array + { + $routeResults = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'built-app-route', ['slug' => $slug]); + if (is_array($routeResults) === false || $routeResults === []) { + return ['error' => 'not_found', 'message' => "No published virtual app found for slug '{$slug}'."]; + } + + $route = $this->toArray(item: $routeResults[0]); + $applicationUuid = ($route['applicationUuid'] ?? null); + if ($applicationUuid === null || $applicationUuid === '') { + return ['error' => 'inconsistent_state', 'message' => 'Route exists but has no applicationUuid.']; + } + + $application = $objectService->find(id: (string) $applicationUuid, register: self::REGISTER_SLUG, schema: 'application'); + if ($application === null) { + return ['error' => 'inconsistent_state', 'message' => 'Route points to an Application that does not exist.']; + } + + return ['application' => $this->toArray(item: $application)]; + + }//end resolveApplicationBySlug() +}//end class diff --git a/lib/Mcp/Handler/ListAppsHandler.php b/lib/Mcp/Handler/ListAppsHandler.php new file mode 100644 index 0000000..cbb0f0b --- /dev/null +++ b/lib/Mcp/Handler/ListAppsHandler.php @@ -0,0 +1,135 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Mcp\Handler; + +/** + * Handles the openbuilt.listApps tool invocation. + */ +class ListAppsHandler extends AbstractToolHandler +{ + + private const ITEMS_CAP = 20; + + private const APP_STATUSES = ['any', 'draft', 'published', 'archived']; + + /** + * Execute the listApps tool. + * + * @param array $args Tool arguments (limit, statusFilter). + * + * @return array + */ + public function handle(array $args): array + { + $validation = $this->validateArgs(args: $args); + if (isset($validation['error']) === true) { + return $this->errorResult(error: 'invalid_arguments', message: $validation['error']); + } + + if ($this->requireAuthenticatedUser() === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in to list virtual apps.'); + } + + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + + $filters = []; + if ($validation['statusFilter'] !== 'any') { + $filters['status'] = $validation['statusFilter']; + } + + $rawApps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', $filters); + if (is_array(value: $rawApps) === false) { + $rawApps = []; + } + + $rawApps = array_slice(array: $rawApps, offset: 0, length: min($validation['limit'], self::ITEMS_CAP)); + + $apps = []; + $sources = []; + foreach ($rawApps as $raw) { + $app = $this->mapApplication(raw: $raw); + $apps[] = $app; + $sources[] = $this->sourceDescriptor(uuid: $app['uuid'], slug: $app['slug'], label: $app['name']); + } + + return ['success' => true, 'apps' => $apps, 'sources' => $sources]; + } catch (\Throwable $e) { + $this->logger->error('OpenBuilt MCP: listApps failed', ['exception' => $e->getMessage()]); + return $this->errorResult(error: 'internal_error', message: 'Failed to retrieve virtual apps.'); + }//end try + + }//end handle() + + /** + * Validate and normalise arguments for the listApps tool. + * + * @param array $args Raw tool arguments. + * + * @return array{limit?: int, statusFilter?: string, error?: string} + */ + private function validateArgs(array $args): array + { + $limit = self::ITEMS_CAP; + if (isset($args['limit']) === true) { + $limit = (int) $args['limit']; + } + + if ($limit < 1 || $limit > 50) { + return ['error' => "Invalid limit {$limit}."]; + } + + $statusFilter = (string) ($args['statusFilter'] ?? 'any'); + if (in_array(needle: $statusFilter, haystack: self::APP_STATUSES, strict: true) === false) { + return ['error' => "Invalid statusFilter '{$statusFilter}'."]; + } + + return ['limit' => $limit, 'statusFilter' => $statusFilter]; + + }//end validateArgs() + + /** + * Map a raw Application object/array into the compact representation returned by listApps. + * + * @param mixed $raw Raw OR Application entity, array, or any JSON-serialisable value. + * + * @return array{uuid: string, slug: string, name: string, description: string, status: string, version: string} + */ + private function mapApplication(mixed $raw): array + { + $app = $this->toArray(item: $raw); + $slug = (string) ($app['slug'] ?? ''); + return [ + 'uuid' => $this->extractUuid(item: $app), + 'slug' => $slug, + 'name' => (string) ($app['name'] ?? $slug), + 'description' => (string) ($app['description'] ?? ''), + 'status' => (string) ($app['status'] ?? 'draft'), + 'version' => (string) ($app['version'] ?? ''), + ]; + + }//end mapApplication() +}//end class diff --git a/lib/Mcp/Handler/PromoteVersionHandler.php b/lib/Mcp/Handler/PromoteVersionHandler.php new file mode 100644 index 0000000..507041a --- /dev/null +++ b/lib/Mcp/Handler/PromoteVersionHandler.php @@ -0,0 +1,134 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Mcp\Handler; + +/** + * Handles the openbuilt.promoteVersion tool invocation. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-59 + */ +class PromoteVersionHandler extends AbstractToolHandler +{ + + private const PROMOTE_STRATEGIES = ['empty-start', 'start-with-source-data', 'migrate-existing-data']; + + /** + * Execute the promoteVersion tool. + * + * @param array $args Tool arguments (appSlug, sourceVersionSlug, strategy). + * + * @return array + */ + public function handle(array $args): array + { + $validation = $this->validateArgs(args: $args); + if (isset($validation['error']) === true) { + return $this->errorResult(error: 'invalid_arguments', message: $validation['error']); + } + + if ($this->requireAuthenticatedUser() === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in to promote a virtual app version.'); + } + + $appSlug = $validation['appSlug']; + $sourceVersionSlug = $validation['sourceVersionSlug']; + $strategy = $validation['strategy']; + + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + + $loaded = $this->loadVersion(objectService: $objectService, appSlug: $appSlug, versionSlug: $sourceVersionSlug); + if (isset($loaded['error']) === true) { + return $this->errorResult(error: $loaded['error'], message: $loaded['message']); + } + + $source = $loaded['version']; + if (($source['promotesTo'] ?? null) === null || $source['promotesTo'] === '') { + return $this->errorResult( + error: 'no_promote_target', + message: "Version '{$sourceVersionSlug}' has no downstream target." + ); + } + + $promotionService = $this->container->get('OCA\OpenBuilt\Service\VersionPromotionService'); + $updatedTarget = $promotionService->promote($source, $strategy); + $targetUuid = $this->extractUuid(item: $updatedTarget); + + return [ + 'success' => true, + 'promoted' => true, + 'strategy' => $strategy, + 'from' => ['uuid' => $this->extractUuid(item: $source), 'slug' => $sourceVersionSlug], + 'to' => [ + 'uuid' => $targetUuid, + 'slug' => (string) ($updatedTarget['slug'] ?? ''), + 'status' => (string) ($updatedTarget['status'] ?? ''), + ], + 'sources' => [$this->sourceDescriptor(uuid: $loaded['appUuid'], slug: $appSlug, label: $loaded['appName'])], + ]; + } catch (\Throwable $e) { + $this->logger->error( + 'OpenBuilt MCP: promoteVersion failed', + ['appSlug' => $appSlug, 'source' => $sourceVersionSlug, 'exception' => $e->getMessage()] + ); + return $this->errorResult(error: 'promote_failed', message: 'Failed to promote version: '.$e->getMessage()); + }//end try + + }//end handle() + + /** + * Validate and extract typed arguments for promoteVersion. + * + * @param array $args Raw tool arguments. + * + * @return array{appSlug?: string, sourceVersionSlug?: string, strategy?: string, error?: string} + */ + private function validateArgs(array $args): array + { + $appSlug = (string) ($args['appSlug'] ?? ''); + $sourceVersionSlug = (string) ($args['sourceVersionSlug'] ?? ''); + $strategy = (string) ($args['strategy'] ?? 'empty-start'); + + if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { + return ['error' => "Invalid appSlug '{$appSlug}'."]; + } + + if ($sourceVersionSlug === '' || $this->isValidSlug(candidate: $sourceVersionSlug) === false) { + return ['error' => "Invalid sourceVersionSlug '{$sourceVersionSlug}'."]; + } + + if (in_array(needle: $strategy, haystack: self::PROMOTE_STRATEGIES, strict: true) === false) { + return ['error' => "Invalid strategy '{$strategy}'."]; + } + + return [ + 'appSlug' => $appSlug, + 'sourceVersionSlug' => $sourceVersionSlug, + 'strategy' => $strategy, + ]; + + }//end validateArgs() +}//end class diff --git a/lib/Mcp/Handler/UpsertMenuItemHandler.php b/lib/Mcp/Handler/UpsertMenuItemHandler.php new file mode 100644 index 0000000..0bd930e --- /dev/null +++ b/lib/Mcp/Handler/UpsertMenuItemHandler.php @@ -0,0 +1,187 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Mcp\Handler; + +/** + * Handles the openbuilt.upsertMenuItem tool invocation. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-42 + */ +class UpsertMenuItemHandler extends AbstractToolHandler +{ + /** + * Execute the upsertMenuItem tool. + * + * @param array $args Tool arguments (appSlug, versionSlug, id, label, icon, route, order). + * + * @return array + */ + public function handle(array $args): array + { + $validation = $this->validateArgs(args: $args); + if (isset($validation['error']) === true) { + return $this->errorResult(error: 'invalid_arguments', message: $validation['error']); + } + + if ($this->requireAuthenticatedUser() === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in to author menu items.'); + } + + $appSlug = $validation['appSlug']; + $versionSlug = $validation['versionSlug']; + $id = $validation['id']; + + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + + $loaded = $this->loadVersion(objectService: $objectService, appSlug: $appSlug, versionSlug: $versionSlug); + if (isset($loaded['error']) === true) { + return $this->errorResult(error: $loaded['error'], message: $loaded['message']); + } + + $version = $loaded['version']; + $manifest = (array) ($version['manifest'] ?? []); + $menu = (array) ($manifest['menu'] ?? []); + + $newItem = [ + 'id' => $id, + 'label' => $validation['label'], + 'icon' => $validation['icon'], + 'route' => $validation['route'], + 'order' => $validation['order'], + ]; + + [$menu, $replaced] = $this->upsertMenuItemInList(menu: $menu, itemId: $id, newItem: $newItem); + + $manifest['menu'] = array_values($menu); + $saved = $this->saveVersionManifest(objectService: $objectService, version: $version, manifest: $manifest); + + $action = 'created'; + if ($replaced === true) { + $action = 'updated'; + } + + return [ + 'success' => true, + 'action' => $action, + 'menuItem' => $newItem, + 'menuCount' => count($menu), + 'version' => [ + 'uuid' => $this->extractUuid(item: $saved), + 'slug' => (string) ($saved['slug'] ?? $versionSlug), + ], + ]; + } catch (\Throwable $e) { + $this->logger->error( + 'OpenBuilt MCP: upsertMenuItem failed', + ['appSlug' => $appSlug, 'id' => $id, 'exception' => $e->getMessage()] + ); + return $this->errorResult(error: 'upsert_failed', message: 'Failed to upsert menu item: '.$e->getMessage()); + }//end try + + }//end handle() + + /** + * Validate and extract typed arguments for upsertMenuItem. + * + * @param array $args Raw tool arguments. + * + * @return array{appSlug?: string, versionSlug?: string, id?: string, label?: string, icon?: string, route?: string, order?: int, error?: string} + */ + private function validateArgs(array $args): array + { + $appSlug = (string) ($args['appSlug'] ?? ''); + $versionSlug = (string) ($args['versionSlug'] ?? 'development'); + $id = (string) ($args['id'] ?? ''); + $label = (string) ($args['label'] ?? ''); + $icon = (string) ($args['icon'] ?? ''); + $route = (string) ($args['route'] ?? ''); + $order = 100; + if (isset($args['order']) === true) { + $order = (int) $args['order']; + } + + if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { + return ['error' => "Invalid appSlug '{$appSlug}'."]; + } + + if ($id === '') { + return ['error' => 'id is required.']; + } + + if ($label === '') { + return ['error' => 'label is required.']; + } + + if ($route === '') { + return ['error' => 'route is required.']; + } + + return [ + 'appSlug' => $appSlug, + 'versionSlug' => $versionSlug, + 'id' => $id, + 'label' => $label, + 'icon' => $icon, + 'route' => $route, + 'order' => $order, + ]; + + }//end validateArgs() + + /** + * Upsert a menu item in the menu list by exact id matching. + * + * Returns the updated menu array and a boolean indicating whether an existing + * item was replaced (true) or a new item was appended (false). + * + * @param array $menu Existing menu list from the manifest. + * @param string $itemId The menu item id to look up. + * @param array $newItem The menu item definition to insert or replace with. + * + * @return array{0: array, 1: bool} + */ + private function upsertMenuItemInList(array $menu, string $itemId, array $newItem): array + { + $replaced = false; + + foreach ($menu as $i => $existing) { + if (is_array($existing) === true && (string) ($existing['id'] ?? '') === $itemId) { + $menu[$i] = $newItem; + $replaced = true; + break; + } + } + + if ($replaced === false) { + $menu[] = $newItem; + } + + return [$menu, $replaced]; + + }//end upsertMenuItemInList() +}//end class diff --git a/lib/Mcp/Handler/UpsertPageHandler.php b/lib/Mcp/Handler/UpsertPageHandler.php new file mode 100644 index 0000000..4f25433 --- /dev/null +++ b/lib/Mcp/Handler/UpsertPageHandler.php @@ -0,0 +1,194 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Mcp\Handler; + +/** + * Handles the openbuilt.upsertPage tool invocation. + */ +class UpsertPageHandler extends AbstractToolHandler +{ + + private const PAGE_TYPES = ['dashboard', 'index', 'detail', 'form']; + + /** + * Execute the upsertPage tool. + * + * @param array $args Tool arguments (appSlug, versionSlug, pageId, title, type, route, config). + * + * @return array + */ + public function handle(array $args): array + { + $validation = $this->validateArgs(args: $args); + if (isset($validation['error']) === true) { + return $this->errorResult(error: 'invalid_arguments', message: $validation['error']); + } + + if ($this->requireAuthenticatedUser() === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in to author pages.'); + } + + $appSlug = $validation['appSlug']; + $versionSlug = $validation['versionSlug']; + $pageId = $validation['pageId']; + + try { + $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); + + $loaded = $this->loadVersion(objectService: $objectService, appSlug: $appSlug, versionSlug: $versionSlug); + if (isset($loaded['error']) === true) { + return $this->errorResult(error: $loaded['error'], message: $loaded['message']); + } + + $version = $loaded['version']; + $manifest = (array) ($version['manifest'] ?? []); + $pages = (array) ($manifest['pages'] ?? []); + + $newPage = [ + 'id' => $pageId, + 'route' => $validation['route'], + 'type' => $validation['type'], + 'title' => $validation['title'], + 'config' => $validation['config'], + ]; + + [$pages, $replaced] = $this->upsertPageInList(pages: $pages, pageId: $pageId, newPage: $newPage); + + $manifest['pages'] = array_values($pages); + $saved = $this->saveVersionManifest(objectService: $objectService, version: $version, manifest: $manifest); + + $action = 'created'; + if ($replaced === true) { + $action = 'updated'; + } + + return [ + 'success' => true, + 'action' => $action, + 'page' => $newPage, + 'pageCount' => count($pages), + 'version' => [ + 'uuid' => $this->extractUuid(item: $saved), + 'slug' => (string) ($saved['slug'] ?? $versionSlug), + ], + ]; + } catch (\Throwable $e) { + $this->logger->error( + 'OpenBuilt MCP: upsertPage failed', + ['appSlug' => $appSlug, 'pageId' => $pageId, 'exception' => $e->getMessage()] + ); + return $this->errorResult(error: 'upsert_failed', message: 'Failed to upsert page: '.$e->getMessage()); + }//end try + + }//end handle() + + /** + * Validate and extract typed arguments for upsertPage. + * + * @param array $args Raw tool arguments. + * + * @return array + */ + private function validateArgs(array $args): array + { + $appSlug = (string) ($args['appSlug'] ?? ''); + $versionSlug = (string) ($args['versionSlug'] ?? 'development'); + $pageId = (string) ($args['pageId'] ?? ''); + $title = (string) ($args['title'] ?? ''); + $type = (string) ($args['type'] ?? ''); + $route = (string) ($args['route'] ?? ''); + $config = $args['config'] ?? []; + + if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { + return ['error' => "Invalid appSlug '{$appSlug}'."]; + } + + if ($pageId === '') { + return ['error' => 'pageId is required.']; + } + + if ($title === '') { + return ['error' => 'title is required.']; + } + + if (in_array(needle: $type, haystack: self::PAGE_TYPES, strict: true) === false) { + return ['error' => "Invalid page type '{$type}'."]; + } + + if ($route === '') { + return ['error' => 'route is required.']; + } + + if (is_array($config) === false) { + $config = []; + } + + return [ + 'appSlug' => $appSlug, + 'versionSlug' => $versionSlug, + 'pageId' => $pageId, + 'title' => $title, + 'type' => $type, + 'route' => $route, + 'config' => $config, + ]; + + }//end validateArgs() + + /** + * Upsert a page into the pages list using case-insensitive id matching. + * + * Returns the updated pages array and a boolean indicating whether an existing + * page was replaced (true) or a new page was appended (false). + * + * @param array $pages Existing pages list from the manifest. + * @param string $pageId The page id to look up (case-insensitive). + * @param array $newPage The page definition to insert or replace with. + * + * @return array{0: array, 1: bool} + */ + private function upsertPageInList(array $pages, string $pageId, array $newPage): array + { + $replaced = false; + $pageIdLc = strtolower($pageId); + + foreach ($pages as $i => $existing) { + if (is_array($existing) === true && strtolower((string) ($existing['id'] ?? '')) === $pageIdLc) { + $pages[$i] = $newPage; + $replaced = true; + break; + } + } + + if ($replaced === false) { + $pages[] = $newPage; + } + + return [$pages, $replaced]; + + }//end upsertPageInList() +}//end class diff --git a/lib/Mcp/Handler/UpsertSchemaHandler.php b/lib/Mcp/Handler/UpsertSchemaHandler.php new file mode 100644 index 0000000..e18c703 --- /dev/null +++ b/lib/Mcp/Handler/UpsertSchemaHandler.php @@ -0,0 +1,260 @@ + + * @copyright 2026 Conduction B.V. + * @license EUPL-1.2 https://joinup.ec.europa.eu/collection/eupl/eupl-text-eupl-12 + * + * @version GIT: + * + * @link https://conduction.nl + * + * SPDX-FileCopyrightText: 2026 Conduction B.V. + * SPDX-License-Identifier: EUPL-1.2 + */ + +declare(strict_types=1); + +namespace OCA\OpenBuilt\Mcp\Handler; + +/** + * Handles the openbuilt.upsertSchema tool invocation. + * + * @spec openspec/changes/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-8 + */ +class UpsertSchemaHandler extends AbstractToolHandler +{ + /** + * Execute the upsertSchema tool. + * + * @param array $args Tool arguments (appSlug, versionSlug, slug, title, description, properties, required). + * + * @return array + */ + public function handle(array $args): array + { + $validation = $this->validateArgs(args: $args); + if (isset($validation['error']) === true) { + return $this->errorResult(error: 'invalid_arguments', message: $validation['error']); + } + + if ($this->requireAuthenticatedUser() === null) { + return $this->errorResult(error: 'forbidden', message: 'You must be signed in to author schemas.'); + } + + $appSlug = $validation['appSlug']; + $versionSlug = $validation['versionSlug']; + $rawSlug = $validation['rawSlug']; + $title = $validation['title']; + $description = $validation['description']; + $properties = $validation['properties']; + $required = $validation['required']; + $namespacedSlug = $appSlug.'-'.$versionSlug.'-'.$rawSlug; + $registerSlug = 'openbuilt-'.$appSlug.'-'.$versionSlug; + + try { + $schemaMapper = $this->container->get('OCA\OpenRegister\Db\SchemaMapper'); + $registerMapper = $this->container->get('OCA\OpenRegister\Db\RegisterMapper'); + + $blob = [ + 'slug' => $namespacedSlug, + 'title' => $title, + 'description' => $description, + 'type' => 'object', + 'required' => array_values(array_filter((array) $required, 'is_string')), + 'properties' => (array) $properties, + ]; + + $existing = $this->findExistingSchema(schemaMapper: $schemaMapper, namespacedSlug: $namespacedSlug); + + if ($existing !== null) { + $schema = $schemaMapper->updateFromArray($existing->getId(), $blob); + $action = 'updated'; + } else { + $schema = $schemaMapper->createFromArray($blob); + $action = 'created'; + $this->attachSchemaToRegister( + registerMapper: $registerMapper, + registerSlug: $registerSlug, + schemaId: $schema->getId() + ); + } + + return [ + 'success' => true, + 'action' => $action, + 'schema' => [ + 'id' => $schema->getId(), + 'slug' => $namespacedSlug, + 'shortSlug' => $rawSlug, + 'title' => $title, + 'register' => $registerSlug, + ], + ]; + } catch (\Throwable $e) { + $this->logger->error( + 'OpenBuilt MCP: upsertSchema failed', + ['appSlug' => $appSlug, 'slug' => $rawSlug, 'exception' => $e->getMessage()] + ); + return $this->errorResult(error: 'upsert_failed', message: 'Failed to upsert schema: '.$e->getMessage()); + }//end try + + }//end handle() + + /** + * Validate and extract typed arguments for upsertSchema. + * + * Delegates slug/title checks to validateSlugsAndTitle() and + * properties/required normalisation to normaliseSchemaBody() so each + * helper stays well within the cyclomatic-complexity threshold. + * + * @param array $args Raw tool arguments. + * + * @return array + */ + private function validateArgs(array $args): array + { + $appSlug = (string) ($args['appSlug'] ?? ''); + $versionSlug = (string) ($args['versionSlug'] ?? 'development'); + $rawSlug = (string) ($args['slug'] ?? ''); + $title = (string) ($args['title'] ?? ''); + $description = (string) ($args['description'] ?? ''); + + $slugError = $this->validateSlugsAndTitle(appSlug: $appSlug, versionSlug: $versionSlug, rawSlug: $rawSlug, title: $title); + if ($slugError !== null) { + return ['error' => $slugError]; + } + + $bodyResult = $this->normaliseSchemaBody(properties: $args['properties'] ?? [], required: $args['required'] ?? []); + if (isset($bodyResult['error']) === true) { + return $bodyResult; + } + + return [ + 'appSlug' => $appSlug, + 'versionSlug' => $versionSlug, + 'rawSlug' => $rawSlug, + 'title' => $title, + 'description' => $description, + 'properties' => $bodyResult['properties'], + 'required' => $bodyResult['required'], + ]; + + }//end validateArgs() + + /** + * Validate the slug and title fields, returning an error string or null on success. + * + * @param string $appSlug Application slug. + * @param string $versionSlug Version slug. + * @param string $rawSlug Schema slug (before namespacing). + * @param string $title Schema title. + * + * @return string|null + */ + private function validateSlugsAndTitle(string $appSlug, string $versionSlug, string $rawSlug, string $title): ?string + { + if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { + return "Invalid appSlug '{$appSlug}'."; + } + + if ($this->isValidSlug(candidate: $versionSlug) === false) { + return "Invalid versionSlug '{$versionSlug}'."; + } + + if ($rawSlug === '' || $this->isValidSlug(candidate: $rawSlug) === false) { + return "Invalid schema slug '{$rawSlug}'."; + } + + if ($title === '') { + return 'title is required.'; + } + + return null; + + }//end validateSlugsAndTitle() + + /** + * Validate and normalise the properties + required fields of the schema body. + * + * @param mixed $properties Raw properties value from tool arguments. + * @param mixed $required Raw required value from tool arguments. + * + * @return array{properties?: array, required?: array, error?: string} + */ + private function normaliseSchemaBody(mixed $properties, mixed $required): array + { + if (is_array($properties) === false || $properties === []) { + return ['error' => 'properties must be a non-empty object of JSON-Schema property definitions.']; + } + + if (is_array($required) === false) { + $required = []; + } + + return ['properties' => $properties, 'required' => $required]; + + }//end normaliseSchemaBody() + + /** + * Find an existing schema by its namespaced slug, or return null. + * + * @param object $schemaMapper OR SchemaMapper instance. + * @param string $namespacedSlug Full namespaced slug to look up. + * + * @return object|null + */ + private function findExistingSchema(object $schemaMapper, string $namespacedSlug): ?object + { + try { + $matches = $schemaMapper->findBySlug($namespacedSlug); + if (is_array($matches) === true && $matches !== []) { + return $matches[0]; + } + } catch (\Throwable $_e) { + // Not found — treat as absent. + } + + return null; + + }//end findExistingSchema() + + /** + * Attach a newly created schema to its per-version register. + * + * Non-fatal: logs a warning but does not re-throw if the register is missing. + * + * @param object $registerMapper OR RegisterMapper instance. + * @param string $registerSlug Slug of the per-version register to attach to. + * @param int $schemaId ID of the freshly created schema. + * + * @return void + */ + private function attachSchemaToRegister(object $registerMapper, string $registerSlug, int $schemaId): void + { + try { + $register = $registerMapper->find($registerSlug, _multitenancy: false); + $current = $register->getSchemas(); + if (is_array($current) === false) { + $current = []; + } + + $register->setSchemas(array_values(array_unique(array_merge($current, [$schemaId])))); + $registerMapper->update($register); + } catch (\Throwable $e) { + $this->logger->warning( + 'OpenBuilt MCP: upsertSchema attach-to-register failed', + ['register' => $registerSlug, 'exception' => $e->getMessage()] + ); + } + + }//end attachSchemaToRegister() +}//end class diff --git a/lib/Mcp/OpenBuiltToolProvider.php b/lib/Mcp/OpenBuiltToolProvider.php index ac5fba1..ab434ec 100644 --- a/lib/Mcp/OpenBuiltToolProvider.php +++ b/lib/Mcp/OpenBuiltToolProvider.php @@ -8,6 +8,9 @@ * new apps, promote versions, and mutate a draft version's manifest (pages, * widgets, menu items) and per-version schemas. * + * This class is a thin dispatcher: all tool logic lives in dedicated handler + * classes under OCA\OpenBuilt\Mcp\Handler\. + * * @category Service * @package OCA\OpenBuilt\Mcp * @@ -43,21 +46,21 @@ use Psr\Log\LoggerInterface; /** - * OpenBuilt MCP Tool Provider. + * OpenBuilt MCP Tool Provider — thin dispatcher to per-tool handler classes. * * Read tools: - * - openbuilt.listApps - * - openbuilt.getAppManifest + * - openbuilt.listApps → ListAppsHandler + * - openbuilt.getAppManifest → GetAppManifestHandler * * Write tools (lifecycle): - * - openbuilt.createApp - * - openbuilt.promoteVersion + * - openbuilt.createApp → CreateAppHandler + * - openbuilt.promoteVersion → PromoteVersionHandler * * Write tools (authoring against the draft version's manifest): - * - openbuilt.upsertSchema (per-version OR schema) - * - openbuilt.upsertPage (manifest.pages slot) - * - openbuilt.addWidget (page.config.widgets append) - * - openbuilt.upsertMenuItem (manifest.menu slot) + * - openbuilt.upsertSchema → UpsertSchemaHandler + * - openbuilt.upsertPage → UpsertPageHandler + * - openbuilt.addWidget → AddWidgetHandler + * - openbuilt.upsertMenuItem → UpsertMenuItemHandler * * Authoring tools default to the `development` version so a misfired tool * call cannot mutate production. To promote the change use promoteVersion. @@ -65,18 +68,6 @@ class OpenBuiltToolProvider implements IMcpToolProvider { - private const ITEMS_CAP = 20; - - private const REGISTER_SLUG = 'openbuilt'; - - private const APP_STATUSES = ['any', 'draft', 'published', 'archived']; - - private const CREATE_PRESETS = ['single', 'dev-prod', 'dev-staging-prod']; - - private const PROMOTE_STRATEGIES = ['empty-start', 'start-with-source-data', 'migrate-existing-data']; - - private const PAGE_TYPES = ['dashboard', 'index', 'detail', 'form']; - /** * Tool catalogue. * @@ -274,873 +265,31 @@ public function getTools(): array */ public function invokeTool(string $toolId, array $arguments): array { - return match ($toolId) { - 'openbuilt.listApps' => $this->handleListApps(args: $arguments), - 'openbuilt.getAppManifest' => $this->handleGetAppManifest(args: $arguments), - 'openbuilt.createApp' => $this->handleCreateApp(args: $arguments), - 'openbuilt.promoteVersion' => $this->handlePromoteVersion(args: $arguments), - 'openbuilt.upsertSchema' => $this->handleUpsertSchema(args: $arguments), - 'openbuilt.upsertPage' => $this->handleUpsertPage(args: $arguments), - 'openbuilt.addWidget' => $this->handleAddWidget(args: $arguments), - 'openbuilt.upsertMenuItem' => $this->handleUpsertMenuItem(args: $arguments), - default => $this->errorResult( - error: 'unknown_tool', - message: "Unknown tool id '{$toolId}'. Available tools: " - .implode(separator: ', ', array: array_column(array: self::TOOL_DESCRIPTORS, column_key: 'id')).'.', - ), - }; - - }//end invokeTool() - - // ========================================================================= - // Read handlers - // ========================================================================= - - /** - * Handle the openbuilt.listApps tool: return matching virtual apps with sources. - * - * @param array $args Tool arguments (limit, statusFilter). - * - * @return array - */ - private function handleListApps(array $args): array - { - $validation = $this->validateListAppsArgs(args: $args); - if (isset($validation['error']) === true) { - return $this->errorResult(error: 'invalid_arguments', message: $validation['error']); - } - - if ($this->requireAuthenticatedUser() === null) { - return $this->errorResult(error: 'forbidden', message: 'You must be signed in to list virtual apps.'); - } - - try { - $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); - - $filters = []; - if ($validation['statusFilter'] !== 'any') { - $filters['status'] = $validation['statusFilter']; - } - - $rawApps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', $filters); - if (is_array(value: $rawApps) === false) { - $rawApps = []; - } - - $rawApps = array_slice(array: $rawApps, offset: 0, length: min($validation['limit'], self::ITEMS_CAP)); - - $apps = []; - $sources = []; - foreach ($rawApps as $raw) { - $app = $this->mapApplication(raw: $raw); - $apps[] = $app; - $sources[] = $this->sourceDescriptor(uuid: $app['uuid'], slug: $app['slug'], label: $app['name']); - } - - return ['success' => true, 'apps' => $apps, 'sources' => $sources]; - } catch (\Throwable $e) { - $this->logger->error('OpenBuilt MCP: listApps failed', ['exception' => $e->getMessage()]); - return $this->errorResult(error: 'internal_error', message: 'Failed to retrieve virtual apps.'); - }//end try - - }//end handleListApps() - - /** - * Handle the openbuilt.getAppManifest tool: resolve a published app slug to its runtime manifest. - * - * @param array $args Tool arguments (slug). - * - * @return array - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-50 - */ - private function handleGetAppManifest(array $args): array - { - $slug = $args['slug'] ?? null; - if ($slug === null || $slug === '') { - return $this->errorResult(error: 'invalid_arguments', message: 'Required argument slug is missing.'); - } - - if ($this->isValidSlug(candidate: (string) $slug) === false) { - return $this->errorResult(error: 'invalid_arguments', message: "Invalid slug '{$slug}'."); - } - - if ($this->requireAuthenticatedUser() === null) { - return $this->errorResult(error: 'forbidden', message: 'You must be signed in to read a virtual app manifest.'); - } - - try { - $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); - $resolved = $this->resolveApplicationBySlug(objectService: $objectService, slug: (string) $slug); - if (isset($resolved['error']) === true) { - return $this->errorResult(error: $resolved['error'], message: $resolved['message']); - } - - $application = $resolved['application']; - $manifest = ($application['manifest'] ?? null); - if (is_array(value: $manifest) === false) { - return $this->errorResult(error: 'no_manifest', message: 'Application has no manifest.'); - } - - $name = (string) ($application['name'] ?? $slug); - return [ - 'success' => true, - 'slug' => (string) $slug, - 'name' => $name, - 'manifest' => $manifest, - 'sources' => [$this->sourceDescriptor(uuid: $this->extractUuid(item: $application), slug: (string) $slug, label: $name)], - ]; - } catch (\Throwable $e) { - $this->logger->error('OpenBuilt MCP: getAppManifest failed', ['slug' => $slug, 'exception' => $e->getMessage()]); - return $this->errorResult(error: 'internal_error', message: 'Failed to resolve manifest.'); - }//end try - - }//end handleGetAppManifest() - - // ========================================================================= - // Lifecycle handlers - // ========================================================================= - - /** - * Handle the openbuilt.createApp tool: create a new virtual app with an initial draft version. - * - * @param array $args Tool arguments (slug, name, description, preset). - * - * @return array - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-8 - */ - private function handleCreateApp(array $args): array - { - $slug = (string) ($args['slug'] ?? ''); - $name = (string) ($args['name'] ?? ''); - $description = (string) ($args['description'] ?? ''); - $preset = (string) ($args['preset'] ?? 'dev-prod'); - - if ($slug === '' || $this->isValidSlug(candidate: $slug) === false) { - return $this->errorResult(error: 'invalid_arguments', message: "Invalid slug '{$slug}'."); - } - - if ($name === '' || strlen($name) < 2 || strlen($name) > 80) { - return $this->errorResult(error: 'invalid_arguments', message: 'Name must be between 2 and 80 characters.'); - } - - if (in_array(needle: $preset, haystack: self::CREATE_PRESETS, strict: true) === false) { - return $this->errorResult(error: 'invalid_arguments', message: "Invalid preset '{$preset}'."); - } - - if ($this->requireAuthenticatedUser() === null) { - return $this->errorResult(error: 'forbidden', message: 'You must be signed in to create a virtual app.'); - } - - try { - $creationService = $this->container->get('OCA\OpenBuilt\Service\ApplicationCreationService'); - $appUuid = $creationService->createApplication( - [ - 'slug' => $slug, - 'name' => $name, - 'description' => $description, - 'preset' => $preset, - ] - ); - - return [ - 'success' => true, - 'created' => true, - 'app' => ['uuid' => $appUuid, 'slug' => $slug, 'name' => $name, 'preset' => $preset], - 'sources' => [$this->sourceDescriptor(uuid: $appUuid, slug: $slug, label: $name)], - ]; - } catch (\Throwable $e) { - $this->logger->error('OpenBuilt MCP: createApp failed', ['slug' => $slug, 'exception' => $e->getMessage()]); - return $this->errorResult(error: 'create_failed', message: 'Failed to create virtual app: '.$e->getMessage()); - }//end try - - }//end handleCreateApp() - - /** - * Handle the openbuilt.promoteVersion tool: promote one app version into its downstream target. - * - * @param array $args Tool arguments (appSlug, sourceVersionSlug, strategy). - * - * @return array - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-59 - */ - private function handlePromoteVersion(array $args): array - { - $appSlug = (string) ($args['appSlug'] ?? ''); - $sourceVersionSlug = (string) ($args['sourceVersionSlug'] ?? ''); - $strategy = (string) ($args['strategy'] ?? 'empty-start'); - - if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { - return $this->errorResult(error: 'invalid_arguments', message: "Invalid appSlug '{$appSlug}'."); - } - - if ($sourceVersionSlug === '' || $this->isValidSlug(candidate: $sourceVersionSlug) === false) { - return $this->errorResult(error: 'invalid_arguments', message: "Invalid sourceVersionSlug '{$sourceVersionSlug}'."); - } - - if (in_array(needle: $strategy, haystack: self::PROMOTE_STRATEGIES, strict: true) === false) { - return $this->errorResult(error: 'invalid_arguments', message: "Invalid strategy '{$strategy}'."); - } - - if ($this->requireAuthenticatedUser() === null) { - return $this->errorResult(error: 'forbidden', message: 'You must be signed in to promote a virtual app version.'); - } - - try { - $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); - - $loaded = $this->loadVersion(objectService: $objectService, appSlug: $appSlug, versionSlug: $sourceVersionSlug); - if (isset($loaded['error']) === true) { - return $this->errorResult(error: $loaded['error'], message: $loaded['message']); - } - - $source = $loaded['version']; - if (($source['promotesTo'] ?? null) === null || $source['promotesTo'] === '') { - return $this->errorResult(error: 'no_promote_target', message: "Version '{$sourceVersionSlug}' has no downstream target."); - } - - $promotionService = $this->container->get('OCA\OpenBuilt\Service\VersionPromotionService'); - $updatedTarget = $promotionService->promote($source, $strategy); - $targetUuid = $this->extractUuid(item: $updatedTarget); - - return [ - 'success' => true, - 'promoted' => true, - 'strategy' => $strategy, - 'from' => ['uuid' => $this->extractUuid(item: $source), 'slug' => $sourceVersionSlug], - 'to' => [ - 'uuid' => $targetUuid, - 'slug' => (string) ($updatedTarget['slug'] ?? ''), - 'status' => (string) ($updatedTarget['status'] ?? ''), - ], - 'sources' => [$this->sourceDescriptor(uuid: $loaded['appUuid'], slug: $appSlug, label: $loaded['appName'])], - ]; - } catch (\Throwable $e) { - $this->logger->error( - 'OpenBuilt MCP: promoteVersion failed', - ['appSlug' => $appSlug, 'source' => $sourceVersionSlug, 'exception' => $e->getMessage()] - ); - return $this->errorResult(error: 'promote_failed', message: 'Failed to promote version: '.$e->getMessage()); - }//end try - - }//end handlePromoteVersion() - - // ========================================================================= - // Authoring handlers - // ========================================================================= - - /** - * Handle the openbuilt.upsertSchema tool: create or update a per-version OR schema. - * - * @param array $args Tool arguments (appSlug, versionSlug, slug, title, description, properties, required). - * - * @return array - */ - private function handleUpsertSchema(array $args): array - { - $appSlug = (string) ($args['appSlug'] ?? ''); - $versionSlug = (string) ($args['versionSlug'] ?? 'development'); - $rawSlug = (string) ($args['slug'] ?? ''); - $title = (string) ($args['title'] ?? ''); - $description = (string) ($args['description'] ?? ''); - $properties = $args['properties'] ?? []; - $required = $args['required'] ?? []; - - if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { - return $this->errorResult(error: 'invalid_arguments', message: "Invalid appSlug '{$appSlug}'."); - } - - if ($this->isValidSlug(candidate: $versionSlug) === false) { - return $this->errorResult(error: 'invalid_arguments', message: "Invalid versionSlug '{$versionSlug}'."); - } - - if ($rawSlug === '' || $this->isValidSlug(candidate: $rawSlug) === false) { - return $this->errorResult(error: 'invalid_arguments', message: "Invalid schema slug '{$rawSlug}'."); - } - - if ($title === '') { - return $this->errorResult(error: 'invalid_arguments', message: 'title is required.'); - } - - if (is_array($properties) === false || $properties === []) { - return $this->errorResult( - error: 'invalid_arguments', - message: 'properties must be a non-empty object of JSON-Schema property definitions.' - ); - } - - if (is_array($required) === false) { - $required = []; - } - - if ($this->requireAuthenticatedUser() === null) { - return $this->errorResult(error: 'forbidden', message: 'You must be signed in to author schemas.'); - } - - try { - $schemaMapper = $this->container->get('OCA\OpenRegister\Db\SchemaMapper'); - $registerMapper = $this->container->get('OCA\OpenRegister\Db\RegisterMapper'); - - $registerSlug = 'openbuilt-'.$appSlug.'-'.$versionSlug; - $namespacedSlug = $appSlug.'-'.$versionSlug.'-'.$rawSlug; - - $blob = [ - 'slug' => $namespacedSlug, - 'title' => $title, - 'description' => $description, - 'type' => 'object', - 'required' => array_values(array_filter((array) $required, 'is_string')), - 'properties' => (array) $properties, - ]; - - // FindBySlug returns Schema[] (may be empty). Take the first hit. - $existing = null; - try { - $matches = $schemaMapper->findBySlug($namespacedSlug); - if (is_array($matches) === true && $matches !== []) { - $existing = $matches[0]; - } - } catch (\Throwable $_e) { - $existing = null; - } - - if ($existing !== null) { - $schema = $schemaMapper->updateFromArray($existing->getId(), $blob); - $action = 'updated'; - } - - if ($existing === null) { - $schema = $schemaMapper->createFromArray($blob); - $action = 'created'; - - // Attach the new schema to the per-version register. - try { - $register = $registerMapper->find($registerSlug, _multitenancy: false); - $current = $register->getSchemas(); - if (is_array($current) === false) { - $current = []; - } - - $register->setSchemas(array_values(array_unique(array_merge($current, [$schema->getId()])))); - $registerMapper->update($register); - } catch (\Throwable $e) { - $this->logger->warning( - 'OpenBuilt MCP: upsertSchema attach-to-register failed', - ['register' => $registerSlug, 'exception' => $e->getMessage()] - ); - } - }//end if - - return [ - 'success' => true, - 'action' => $action, - 'schema' => [ - 'id' => $schema->getId(), - 'slug' => $namespacedSlug, - 'shortSlug' => $rawSlug, - 'title' => $title, - 'register' => $registerSlug, - ], - ]; - } catch (\Throwable $e) { - $this->logger->error('OpenBuilt MCP: upsertSchema failed', ['appSlug' => $appSlug, 'slug' => $rawSlug, 'exception' => $e->getMessage()]); - return $this->errorResult(error: 'upsert_failed', message: 'Failed to upsert schema: '.$e->getMessage()); - }//end try - - }//end handleUpsertSchema() - - /** - * Handle the openbuilt.upsertPage tool: create or update a page entry in the draft manifest. - * - * @param array $args Tool arguments (appSlug, versionSlug, pageId, title, type, route, config). - * - * @return array - */ - private function handleUpsertPage(array $args): array - { - $appSlug = (string) ($args['appSlug'] ?? ''); - $versionSlug = (string) ($args['versionSlug'] ?? 'development'); - $pageId = (string) ($args['pageId'] ?? ''); - $title = (string) ($args['title'] ?? ''); - $type = (string) ($args['type'] ?? ''); - $route = (string) ($args['route'] ?? ''); - $config = $args['config'] ?? []; - - if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { - return $this->errorResult(error: 'invalid_arguments', message: "Invalid appSlug '{$appSlug}'."); - } - - if ($pageId === '') { - return $this->errorResult(error: 'invalid_arguments', message: 'pageId is required.'); - } - - if ($title === '') { - return $this->errorResult(error: 'invalid_arguments', message: 'title is required.'); - } - - if (in_array(needle: $type, haystack: self::PAGE_TYPES, strict: true) === false) { - return $this->errorResult(error: 'invalid_arguments', message: "Invalid page type '{$type}'."); - } - - if ($route === '') { - return $this->errorResult(error: 'invalid_arguments', message: 'route is required.'); - } - - if (is_array($config) === false) { - $config = []; - } - - if ($this->requireAuthenticatedUser() === null) { - return $this->errorResult(error: 'forbidden', message: 'You must be signed in to author pages.'); - } - - try { - $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); - - $loaded = $this->loadVersion(objectService: $objectService, appSlug: $appSlug, versionSlug: $versionSlug); - if (isset($loaded['error']) === true) { - return $this->errorResult(error: $loaded['error'], message: $loaded['message']); - } - - $version = $loaded['version']; - $manifest = (array) ($version['manifest'] ?? []); - $pages = (array) ($manifest['pages'] ?? []); - - $newPage = [ - 'id' => $pageId, - 'route' => $route, - 'type' => $type, - 'title' => $title, - 'config' => $config, - ]; - - // Case-insensitive id lookup so the LLM doesn't have to remember - // exact casing ("dashboard" must still find "Dashboard"). - $replaced = false; - $pageIdLc = strtolower($pageId); - foreach ($pages as $i => $existing) { - if (is_array($existing) === true && strtolower((string) ($existing['id'] ?? '')) === $pageIdLc) { - $pages[$i] = $newPage; - $replaced = true; - break; - } - } - - if ($replaced === false) { - $pages[] = $newPage; - } - - $manifest['pages'] = array_values($pages); - $saved = $this->saveVersionManifest(objectService: $objectService, version: $version, manifest: $manifest); - - $action = 'created'; - if ($replaced === true) { - $action = 'updated'; - } - - return [ - 'success' => true, - 'action' => $action, - 'page' => $newPage, - 'pageCount' => count($pages), - 'version' => [ - 'uuid' => $this->extractUuid(item: $saved), - 'slug' => (string) ($saved['slug'] ?? $versionSlug), - ], - ]; - } catch (\Throwable $e) { - $this->logger->error('OpenBuilt MCP: upsertPage failed', ['appSlug' => $appSlug, 'pageId' => $pageId, 'exception' => $e->getMessage()]); - return $this->errorResult(error: 'upsert_failed', message: 'Failed to upsert page: '.$e->getMessage()); - }//end try - - }//end handleUpsertPage() - - /** - * Handle the openbuilt.addWidget tool: append a widget to a page's config in the draft manifest. - * - * @param array $args Tool arguments (appSlug, versionSlug, pageId, widgetType, widgetConfig). - * - * @return array - */ - private function handleAddWidget(array $args): array - { - $appSlug = (string) ($args['appSlug'] ?? ''); - $versionSlug = (string) ($args['versionSlug'] ?? 'development'); - $pageId = (string) ($args['pageId'] ?? ''); - $widgetType = (string) ($args['widgetType'] ?? ''); - $widgetConfig = $args['widgetConfig'] ?? []; - - if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { - return $this->errorResult(error: 'invalid_arguments', message: "Invalid appSlug '{$appSlug}'."); - } - - if ($pageId === '') { - return $this->errorResult(error: 'invalid_arguments', message: 'pageId is required.'); - } - - if ($widgetType === '') { - return $this->errorResult(error: 'invalid_arguments', message: 'widgetType is required.'); - } - - if (is_array($widgetConfig) === false) { - $widgetConfig = []; - } - - if ($this->requireAuthenticatedUser() === null) { - return $this->errorResult(error: 'forbidden', message: 'You must be signed in to add widgets.'); - } - - try { - $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); - - $loaded = $this->loadVersion(objectService: $objectService, appSlug: $appSlug, versionSlug: $versionSlug); - if (isset($loaded['error']) === true) { - return $this->errorResult(error: $loaded['error'], message: $loaded['message']); - } - - $version = $loaded['version']; - $manifest = (array) ($version['manifest'] ?? []); - $pages = (array) ($manifest['pages'] ?? []); - - // Case-insensitive lookup (see upsertPage rationale). - $foundIdx = null; - $pageIdLc = strtolower($pageId); - foreach ($pages as $i => $existing) { - if (is_array($existing) === true && strtolower((string) ($existing['id'] ?? '')) === $pageIdLc) { - $foundIdx = $i; - break; - } - } - - if ($foundIdx === null) { - return $this->errorResult(error: 'not_found', message: "Page '{$pageId}' not found in manifest."); - } - - $page = $pages[$foundIdx]; - $pageConfig = (array) ($page['config'] ?? []); - $widgets = (array) ($pageConfig['widgets'] ?? []); - $widget = ['type' => $widgetType, 'config' => $widgetConfig]; - $widgets[] = $widget; - $pageConfig['widgets'] = $widgets; - $page['config'] = $pageConfig; - $pages[$foundIdx] = $page; - $manifest['pages'] = array_values($pages); - - $saved = $this->saveVersionManifest(objectService: $objectService, version: $version, manifest: $manifest); - - return [ - 'success' => true, - 'added' => true, - 'widget' => $widget, - 'pageId' => $pageId, - 'widgetCount' => count($widgets), - 'version' => [ - 'uuid' => $this->extractUuid(item: $saved), - 'slug' => (string) ($saved['slug'] ?? $versionSlug), - ], - ]; - } catch (\Throwable $e) { - $this->logger->error('OpenBuilt MCP: addWidget failed', ['appSlug' => $appSlug, 'pageId' => $pageId, 'exception' => $e->getMessage()]); - return $this->errorResult(error: 'add_failed', message: 'Failed to add widget: '.$e->getMessage()); - }//end try - - }//end handleAddWidget() - - /** - * Handle the openbuilt.upsertMenuItem tool: create or update a top-level menu item in the draft manifest. - * - * @param array $args Tool arguments (appSlug, versionSlug, id, label, icon, route, order). - * - * @return array - * - * @spec openspec/changes/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-42 - */ - private function handleUpsertMenuItem(array $args): array - { - $appSlug = (string) ($args['appSlug'] ?? ''); - $versionSlug = (string) ($args['versionSlug'] ?? 'development'); - $id = (string) ($args['id'] ?? ''); - $label = (string) ($args['label'] ?? ''); - $icon = (string) ($args['icon'] ?? ''); - $route = (string) ($args['route'] ?? ''); - $order = 100; - if (isset($args['order']) === true) { - $order = (int) $args['order']; - } - - if ($appSlug === '' || $this->isValidSlug(candidate: $appSlug) === false) { - return $this->errorResult(error: 'invalid_arguments', message: "Invalid appSlug '{$appSlug}'."); - } - - if ($id === '') { - return $this->errorResult(error: 'invalid_arguments', message: 'id is required.'); - } - - if ($label === '') { - return $this->errorResult(error: 'invalid_arguments', message: 'label is required.'); - } - - if ($route === '') { - return $this->errorResult(error: 'invalid_arguments', message: 'route is required.'); - } - - if ($this->requireAuthenticatedUser() === null) { - return $this->errorResult(error: 'forbidden', message: 'You must be signed in to author menu items.'); - } - - try { - $objectService = $this->container->get('OCA\OpenRegister\Service\ObjectService'); - - $loaded = $this->loadVersion(objectService: $objectService, appSlug: $appSlug, versionSlug: $versionSlug); - if (isset($loaded['error']) === true) { - return $this->errorResult(error: $loaded['error'], message: $loaded['message']); - } - - $version = $loaded['version']; - $manifest = (array) ($version['manifest'] ?? []); - $menu = (array) ($manifest['menu'] ?? []); - - $newItem = ['id' => $id, 'label' => $label, 'icon' => $icon, 'route' => $route, 'order' => $order]; - - $replaced = false; - foreach ($menu as $i => $existing) { - if (is_array($existing) === true && (string) ($existing['id'] ?? '') === $id) { - $menu[$i] = $newItem; - $replaced = true; - break; - } - } - - if ($replaced === false) { - $menu[] = $newItem; - } - - $manifest['menu'] = array_values($menu); - $saved = $this->saveVersionManifest(objectService: $objectService, version: $version, manifest: $manifest); - - $action = 'created'; - if ($replaced === true) { - $action = 'updated'; - } - - return [ - 'success' => true, - 'action' => $action, - 'menuItem' => $newItem, - 'menuCount' => count($menu), - 'version' => [ - 'uuid' => $this->extractUuid(item: $saved), - 'slug' => (string) ($saved['slug'] ?? $versionSlug), - ], - ]; - } catch (\Throwable $e) { - $this->logger->error('OpenBuilt MCP: upsertMenuItem failed', ['appSlug' => $appSlug, 'id' => $id, 'exception' => $e->getMessage()]); - return $this->errorResult(error: 'upsert_failed', message: 'Failed to upsert menu item: '.$e->getMessage()); - }//end try - - }//end handleUpsertMenuItem() - - // ========================================================================= - // Shared helpers - // ========================================================================= - - /** - * Resolve to {version, appUuid, appName}, or {error,message}. - * - * @param object $objectService OpenRegister ObjectService instance used for slug lookups. - * @param string $appSlug Application slug to resolve. - * @param string $versionSlug ApplicationVersion slug to resolve under the application. - * - * @return array{version?: array, appUuid?: string, appName?: string, error?: string, message?: string} - */ - private function loadVersion(object $objectService, string $appSlug, string $versionSlug): array - { - $apps = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'application', ['slug' => $appSlug]); - if (is_array($apps) === false || $apps === []) { - return ['error' => 'not_found', 'message' => "No virtual app found for slug '{$appSlug}'."]; - } - - $app = $this->toArray(item: $apps[0]); - $appUuid = $this->extractUuid(item: $app); - - $versions = $objectService->searchObjectsBySlug( - self::REGISTER_SLUG, - 'applicationVersion', - ['application' => $appUuid, 'slug' => $versionSlug] - ); - if (is_array($versions) === false || $versions === []) { - return ['error' => 'not_found', 'message' => "No version '{$versionSlug}' found for app '{$appSlug}'."]; - } - - return [ - 'version' => $this->toArray(item: $versions[0]), - 'appUuid' => $appUuid, - 'appName' => (string) ($app['name'] ?? $appSlug), + // Handler class names are referenced as strings to keep the coupling + // count of this dispatcher class within the PHPMD threshold. + $handlerMap = [ + 'openbuilt.listApps' => 'OCA\OpenBuilt\Mcp\Handler\ListAppsHandler', + 'openbuilt.getAppManifest' => 'OCA\OpenBuilt\Mcp\Handler\GetAppManifestHandler', + 'openbuilt.createApp' => 'OCA\OpenBuilt\Mcp\Handler\CreateAppHandler', + 'openbuilt.promoteVersion' => 'OCA\OpenBuilt\Mcp\Handler\PromoteVersionHandler', + 'openbuilt.upsertSchema' => 'OCA\OpenBuilt\Mcp\Handler\UpsertSchemaHandler', + 'openbuilt.upsertPage' => 'OCA\OpenBuilt\Mcp\Handler\UpsertPageHandler', + 'openbuilt.addWidget' => 'OCA\OpenBuilt\Mcp\Handler\AddWidgetHandler', + 'openbuilt.upsertMenuItem' => 'OCA\OpenBuilt\Mcp\Handler\UpsertMenuItemHandler', ]; - }//end loadVersion() - - /** - * Save an ApplicationVersion with a new manifest. Retains the full payload so - * OR's `required[]` validator does not reject a partial save. - * - * @param object $objectService OpenRegister ObjectService instance used to persist the version. - * @param array $version The existing ApplicationVersion as an associative array. - * @param array $manifest The new manifest blob to write onto the version. - * - * @return array - */ - private function saveVersionManifest(object $objectService, array $version, array $manifest): array - { - $versionUuid = $this->extractUuid(item: $version); - $payload = $version; - $payload['manifest'] = $manifest; - - // Drop OR-internal `@self` / metadata keys that some readers tack on so - // saveObject treats the input as a clean property bag. - unset($payload['@self'], $payload['id'], $payload['uuid']); - - $saved = $objectService->saveObject( - object: $payload, - register: self::REGISTER_SLUG, - schema: 'applicationVersion', - uuid: $versionUuid, - ); - - return $this->toArray(item: $saved); - - }//end saveVersionManifest() - - /** - * Validate and normalise arguments for the openbuilt.listApps tool. - * - * @param array $args Raw tool arguments. - * - * @return array{limit?: int, statusFilter?: string, error?: string} - */ - private function validateListAppsArgs(array $args): array - { - $limit = self::ITEMS_CAP; - if (isset($args['limit']) === true) { - $limit = (int) $args['limit']; - } - - if ($limit < 1 || $limit > 50) { - return ['error' => "Invalid limit {$limit}."]; + if (isset($handlerMap[$toolId]) === true) { + return $this->makeHandler(class: $handlerMap[$toolId])->handle($arguments); } - $statusFilter = (string) ($args['statusFilter'] ?? 'any'); - if (in_array(needle: $statusFilter, haystack: self::APP_STATUSES, strict: true) === false) { - return ['error' => "Invalid statusFilter '{$statusFilter}'."]; - } - - return ['limit' => $limit, 'statusFilter' => $statusFilter]; - - }//end validateListAppsArgs() - - /** - * Resolve a published-app slug to its underlying Application object via the built-app-route schema. - * - * @param object $objectService OpenRegister ObjectService instance used for slug lookups. - * @param string $slug Public route slug of the published virtual app. - * - * @return array{application?: array, error?: string, message?: string} - */ - private function resolveApplicationBySlug(object $objectService, string $slug): array - { - $routeResults = $objectService->searchObjectsBySlug(self::REGISTER_SLUG, 'built-app-route', ['slug' => $slug]); - if (is_array($routeResults) === false || $routeResults === []) { - return ['error' => 'not_found', 'message' => "No published virtual app found for slug '{$slug}'."]; - } - - $route = $this->toArray(item: $routeResults[0]); - $applicationUuid = ($route['applicationUuid'] ?? null); - if ($applicationUuid === null || $applicationUuid === '') { - return ['error' => 'inconsistent_state', 'message' => 'Route exists but has no applicationUuid.']; - } - - $application = $objectService->find(id: (string) $applicationUuid, register: self::REGISTER_SLUG, schema: 'application'); - if ($application === null) { - return ['error' => 'inconsistent_state', 'message' => 'Route points to an Application that does not exist.']; - } - - return ['application' => $this->toArray(item: $application)]; - - }//end resolveApplicationBySlug() - - /** - * Map a raw Application object/array into the compact representation returned by listApps. - * - * @param mixed $raw Raw OR Application entity, array, or any JSON-serialisable value. - * - * @return array{uuid: string, slug: string, name: string, description: string, status: string, version: string} - */ - private function mapApplication(mixed $raw): array - { - $app = $this->toArray(item: $raw); - $slug = (string) ($app['slug'] ?? ''); return [ - 'uuid' => $this->extractUuid(item: $app), - 'slug' => $slug, - 'name' => (string) ($app['name'] ?? $slug), - 'description' => (string) ($app['description'] ?? ''), - 'status' => (string) ($app['status'] ?? 'draft'), - 'version' => (string) ($app['version'] ?? ''), + 'isError' => true, + 'error' => 'unknown_tool', + 'message' => "Unknown tool id '{$toolId}'. Available tools: " + .implode(separator: ', ', array: array_column(array: self::TOOL_DESCRIPTORS, column_key: 'id')).'.', ]; - }//end mapApplication() - - /** - * Build an MCP "source" descriptor pointing at the OpenBuilt app deep link. - * - * @param string $uuid Application UUID. - * @param string $slug Application slug used to build the deep link. - * @param string $label Human-readable label for the source descriptor. - * - * @return array{type: string, uuid: string, url: string, label: string} - */ - private function sourceDescriptor(string $uuid, string $slug, string $label): array - { - return ['type' => 'openbuilt.application', 'uuid' => $uuid, 'url' => $this->buildDeepLink(slug: $slug), 'label' => $label]; - - }//end sourceDescriptor() - - /** - * Build a uniform MCP error envelope used by every handler. - * - * @param string $error Machine-readable error code (e.g. "invalid_arguments"). - * @param string $message Human-readable, end-user-safe error message. - * - * @return array{isError: true, error: string, message: string} - */ - private function errorResult(string $error, string $message): array - { - return ['isError' => true, 'error' => $error, 'message' => $message]; - - }//end errorResult() - - /** - * Return the authenticated user's UID, or null if there is no session user. - * - * @return string|null - */ - private function requireAuthenticatedUser(): ?string - { - $user = $this->userSession->getUser(); - if ($user === null) { - return null; - } - - $uid = $user->getUID(); - if ($uid === '') { - return null; - } - - return $uid; - - }//end requireAuthenticatedUser() + }//end invokeTool() /** * Check whether the given user id belongs to the admin group. @@ -1156,74 +305,15 @@ public function isAdmin(string $userId): bool }//end isAdmin() /** - * Validate that a candidate string matches the OpenBuilt slug shape (lowercase, hyphen-separated, 2-48 chars). - * - * @param string $candidate Candidate slug to validate. - * - * @return bool - */ - private function isValidSlug(string $candidate): bool - { - if (strlen($candidate) < 2 || strlen($candidate) > 48) { - return false; - } - - return (bool) preg_match('/^[a-z0-9][a-z0-9-]*[a-z0-9]$/', $candidate); - - }//end isValidSlug() - - /** - * Build a Nextcloud deep link into the OpenBuilt builder for the given application slug. - * - * @param string $slug Application slug (empty falls back to the app root). - * - * @return string - */ - private function buildDeepLink(string $slug): string - { - if ($slug === '') { - return '/apps/openbuilt'; - } - - return "/apps/openbuilt/builder/{$slug}"; - - }//end buildDeepLink() - - /** - * Coerce an OR entity, array, or generic value into an associative array. - * - * @param mixed $item Value to coerce (OR entity, array, or jsonSerialize-able object). - * - * @return array - */ - private function toArray(mixed $item): array - { - if (is_array($item) === true) { - return $item; - } - - if (is_object($item) === true && method_exists($item, 'jsonSerialize') === true) { - $serialised = $item->jsonSerialize(); - if (is_array($serialised) === true) { - return $serialised; - } - } - - return (array) $item; - - }//end toArray() - - /** - * Extract a UUID from a normalised OR object array, falling back through common metadata locations. + * Instantiate a handler class, injecting the shared collaborators. * - * @param array $item Normalised OR object as an associative array. + * @param class-string $class Fully qualified handler class name. * - * @return string + * @return \OCA\OpenBuilt\Mcp\Handler\AbstractToolHandler */ - private function extractUuid(array $item): string + private function makeHandler(string $class): \OCA\OpenBuilt\Mcp\Handler\AbstractToolHandler { - $uuid = $item['uuid'] ?? $item['id'] ?? ($item['@self']['uuid'] ?? ($item['@self']['id'] ?? '')); - return (string) $uuid; + return new $class($this->userSession, $this->container, $this->logger); - }//end extractUuid() + }//end makeHandler() }//end class