diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 5f01cca..e2cc12b 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -1,2 +1,5 @@ # Retrofit annotation commit (opsx-annotate, 2026-05-24) 770ffed41a22f3a45e00e98a8484ca72330eefc9 + +# Retrofit reverse-spec annotations (opsx-reverse-spec, 2026-05-24, openbuilt-runtime MCP) +8af6efa diff --git a/lib/Mcp/OpenBuiltToolProvider.php b/lib/Mcp/OpenBuiltToolProvider.php index ba2b92c..ac5fba1 100644 --- a/lib/Mcp/OpenBuiltToolProvider.php +++ b/lib/Mcp/OpenBuiltToolProvider.php @@ -23,6 +23,10 @@ * @spec openspec/changes/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-42 * @spec openspec/changes/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-50 * @spec openspec/changes/retrofit-2026-05-24-annotate-openbuilt/tasks.md#task-59 + * @spec openspec/changes/retrofit-2026-05-24-openbuilt-runtime-mcp/tasks.md#task-1 + * @spec openspec/changes/retrofit-2026-05-24-openbuilt-runtime-mcp/tasks.md#task-2 + * @spec openspec/changes/retrofit-2026-05-24-openbuilt-runtime-mcp/tasks.md#task-3 + * @spec openspec/changes/retrofit-2026-05-24-openbuilt-runtime-mcp/tasks.md#task-4 * * SPDX-FileCopyrightText: 2026 Conduction B.V. * SPDX-License-Identifier: EUPL-1.2 diff --git a/openspec/changes/archive/retrofit-2026-05-24-openbuilt-runtime-mcp/design.md b/openspec/changes/archive/retrofit-2026-05-24-openbuilt-runtime-mcp/design.md new file mode 100644 index 0000000..9dce266 --- /dev/null +++ b/openspec/changes/archive/retrofit-2026-05-24-openbuilt-runtime-mcp/design.md @@ -0,0 +1,60 @@ +# Design — Retrofit openbuilt-runtime MCP surface + +> Retrofit change. Tasks describe retroactive annotation, not new implementation +> work. The code already exists at HEAD. + +## Context + +`lib/Mcp/OpenBuiltToolProvider.php` (1225 lines) was shipped under the +AI-companion fleet rollout (see project memory note +`project_ai-companion-fleet-rollout` — 2026-05-12). It implements +`OCA\OpenRegister\Mcp\IMcpToolProvider`, the per-app extension point +defined by the orchestrator in OpenRegister #1466 and ADR-019 +(Pluggable Integration Registry). The 16 methods on the provider were +swept into Bucket 2a (`openbuilt-runtime`) by the 2026-05-24 coverage +scan because the `openbuilt-runtime` spec already covers the +slug-keyed manifest endpoint + nested `CnAppRoot` mount but does not +name the MCP surface as a deliverable of the same capability. + +The 16 methods cleanly cluster into four observable behaviours: + +| REQ | Methods | Observable behaviour | +|---|---|---| +| REQ-OBR-MCP-001 | getAppId, getTools, invokeTool, errorResult | The static MCP contract surface | +| REQ-OBR-MCP-002 | requireAuthenticatedUser, isAdmin, validateListAppsArgs, isValidSlug | Auth-gated dispatch + arg validation | +| REQ-OBR-MCP-003 | resolveApplicationBySlug, mapApplication, sourceDescriptor, buildDeepLink, toArray, extractUuid | Application resolution + uniform mapping | +| REQ-OBR-MCP-004 | loadVersion, saveVersionManifest | Draft-version manifest mutation isolation | + +Helpers like `toArray` and `extractUuid` deliberately do NOT get their +own REQs — they only exist to support REQ-OBR-MCP-003. Splitting them +out would inflate the REQ count without adding observable behaviour. + +## Decisions + +- **Extend not cluster.** The MCP surface is a tool-call entry point + into the same runtime that REQ-OBR-001..013 already specify + (manifest endpoints, version snapshots, RBAC, etc.). The methods + read/write the same objects via OpenRegister, so they belong as + delta REQs on `openbuilt-runtime` rather than a new capability. +- **4 REQs, not 16.** Bias toward fewer REQs per the playbook. One + observable behaviour per REQ. Helpers fold into the REQ they + support. +- **Bug noted, not fixed.** `isValidSlug` duplicates the + `SlugValidator` service surface. The Notes block on + REQ-OBR-MCP-002 records this as a TODO; this PR does not silently + collapse them. +- **Auth posture mirrored from observed code.** Every handler in the + provider calls `requireAuthenticatedUser` before any OpenRegister + read/write — REQ-OBR-MCP-002 reflects that, including the + short-circuit envelope shape. +- **Default `versionSlug` to `development`.** Observed in every + authoring tool descriptor; REQ-OBR-MCP-004 codifies it as a safety + invariant against accidental production mutation. + +## Out of scope + +- Tightening `isValidSlug` against `SlugValidator` — separate PR. +- Adding `openbuilt.deleteApp` / `openbuilt.archiveVersion` tools — not + implemented today, not specified here. +- Per-tool turn-budget / rate-limit semantics — orchestrator concern, + not provider concern. diff --git a/openspec/changes/archive/retrofit-2026-05-24-openbuilt-runtime-mcp/proposal.md b/openspec/changes/archive/retrofit-2026-05-24-openbuilt-runtime-mcp/proposal.md new file mode 100644 index 0000000..6aeb667 --- /dev/null +++ b/openspec/changes/archive/retrofit-2026-05-24-openbuilt-runtime-mcp/proposal.md @@ -0,0 +1,38 @@ +# Retrofit — openbuilt-runtime (MCP surface) + +Describes observed behaviour of 16 methods under `lib/Mcp/OpenBuiltToolProvider.php` +as 4 new REQs added to the `openbuilt-runtime` capability. Code already exists — +this change retroactively specifies it. + +## Affected code units + +- lib/Mcp/OpenBuiltToolProvider.php::getAppId +- lib/Mcp/OpenBuiltToolProvider.php::getTools +- lib/Mcp/OpenBuiltToolProvider.php::invokeTool +- lib/Mcp/OpenBuiltToolProvider.php::errorResult +- lib/Mcp/OpenBuiltToolProvider.php::requireAuthenticatedUser +- lib/Mcp/OpenBuiltToolProvider.php::isAdmin +- lib/Mcp/OpenBuiltToolProvider.php::validateListAppsArgs +- lib/Mcp/OpenBuiltToolProvider.php::isValidSlug +- lib/Mcp/OpenBuiltToolProvider.php::resolveApplicationBySlug +- lib/Mcp/OpenBuiltToolProvider.php::mapApplication +- lib/Mcp/OpenBuiltToolProvider.php::sourceDescriptor +- lib/Mcp/OpenBuiltToolProvider.php::buildDeepLink +- lib/Mcp/OpenBuiltToolProvider.php::toArray +- lib/Mcp/OpenBuiltToolProvider.php::extractUuid +- lib/Mcp/OpenBuiltToolProvider.php::loadVersion +- lib/Mcp/OpenBuiltToolProvider.php::saveVersionManifest + +## Approach + +- Group the 16 methods into 4 REQs by observable behaviour (provider contract, + auth-gated dispatch + arg validation, application resolution + response + mapping, draft-version manifest mutation isolation). +- One REQ per distinct observable behaviour; helpers like `toArray` and + `extractUuid` fold into the resolution REQ they support rather than getting + their own REQ. +- Notes flag the duplicated slug-validation surface (`isValidSlug` overlaps the + existing `SlugValidator` service) for future tightening — not silently fixed. + +Source: `openspec/coverage-report.md` generated 2026-05-24. See +[retrofit playbook](../../../../hydra/.github/docs/claude/retrofit.md). diff --git a/openspec/changes/archive/retrofit-2026-05-24-openbuilt-runtime-mcp/specs/openbuilt-runtime/spec.md b/openspec/changes/archive/retrofit-2026-05-24-openbuilt-runtime-mcp/specs/openbuilt-runtime/spec.md new file mode 100644 index 0000000..8b22b0a --- /dev/null +++ b/openspec/changes/archive/retrofit-2026-05-24-openbuilt-runtime-mcp/specs/openbuilt-runtime/spec.md @@ -0,0 +1,203 @@ +--- +retrofit_extensions: + - REQ-OBR-MCP-001 + - REQ-OBR-MCP-002 + - REQ-OBR-MCP-003 + - REQ-OBR-MCP-004 +--- + +# openbuilt-runtime Specification Delta (Retrofit — MCP surface) + +## Requirements + +### Requirement: MCP tool-provider contract + +The OpenBuilt MCP surface SHALL be implemented by a class +(`OCA\OpenBuilt\Mcp\OpenBuiltToolProvider`) that implements +`OCA\OpenRegister\Mcp\IMcpToolProvider`. The provider SHALL declare its +host Nextcloud app id (`openbuilt`), expose a static tool catalogue of +read tools (`openbuilt.listApps`, `openbuilt.getAppManifest`) and write +tools covering virtual-app lifecycle (`openbuilt.createApp`, +`openbuilt.promoteVersion`) and draft-version authoring +(`openbuilt.upsertSchema`, `openbuilt.upsertPage`, `openbuilt.addWidget`, +`openbuilt.upsertMenuItem`), and SHALL dispatch each invocation by tool +id to the matching internal handler. Unknown tool ids SHALL return a +uniform error envelope of shape +`{ isError: true, error, message }` carrying the machine-readable code +`unknown_tool` and a human-readable message that lists the available +tool ids. + +**ID:** REQ-OBR-MCP-001 + +#### Scenario: Provider reports the OpenBuilt app id + +- **WHEN** OpenRegister's MCP orchestrator calls `getAppId()` on the + provider +- **THEN** the provider returns the string `openbuilt` + +#### Scenario: Catalogue surfaces all OpenBuilt tools + +- **WHEN** OpenRegister's MCP orchestrator calls `getTools()` +- **THEN** the returned array contains the eight tool descriptors + (`openbuilt.listApps`, `openbuilt.getAppManifest`, + `openbuilt.createApp`, `openbuilt.promoteVersion`, + `openbuilt.upsertSchema`, `openbuilt.upsertPage`, + `openbuilt.addWidget`, `openbuilt.upsertMenuItem`), each with an + `inputSchema` of `type: object` + +#### Scenario: Unknown tool id returns a structured error + +- **WHEN** OpenRegister's MCP orchestrator calls + `invokeTool('openbuilt.nope', [])` +- **THEN** the response is `{ isError: true, error: 'unknown_tool', + message: ... }` and `message` lists the available tool ids + +### Requirement: Auth-gated dispatch with arg validation + +Every MCP tool exposed by this provider SHALL require an authenticated +Nextcloud session. The provider SHALL resolve the active user via +`IUserSession`; if no user is signed in (or the user UID is empty), the +handler SHALL short-circuit with an `{ isError: true, error: +'forbidden', message }` envelope before performing any read or write. +Read-tool argument shape SHALL be validated up-front — `listApps` +SHALL clamp `limit` to the range 1..50 and SHALL reject any +`statusFilter` outside the closed set `{any, draft, published, +archived}` with `{ isError: true, error: 'invalid_arguments' }`. Slug +arguments accepted by the write surface SHALL conform to a shared +pattern (lowercase alphanumeric, hyphen-separated, 2..48 chars, +matching `^[a-z0-9][a-z0-9-]*[a-z0-9]$`). A public `isAdmin($userId)` +helper SHALL delegate to `IGroupManager::isAdmin` so callers can probe +admin posture without re-implementing the check. + +**ID:** REQ-OBR-MCP-002 + +#### Scenario: Unauthenticated caller is rejected + +- **WHEN** the MCP orchestrator invokes any OpenBuilt tool with no + active `IUserSession` user +- **THEN** the response is `{ isError: true, error: 'forbidden', ... }` + and no OpenRegister read/write is attempted + +#### Scenario: listApps rejects an out-of-range limit + +- **WHEN** an authenticated caller invokes `openbuilt.listApps` with + `limit: 0` (or `limit: 51`) +- **THEN** the response is `{ isError: true, error: + 'invalid_arguments', message: "Invalid limit 0." }` + +#### Scenario: listApps rejects an unknown statusFilter + +- **WHEN** an authenticated caller invokes `openbuilt.listApps` with + `statusFilter: 'weird'` +- **THEN** the response is `{ isError: true, error: + 'invalid_arguments', message: "Invalid statusFilter 'weird'." }` + +#### Scenario: isAdmin reports admin membership + +- **WHEN** a caller queries `isAdmin('alice')` and Nextcloud's group + manager reports Alice in the admin group +- **THEN** the helper returns `true` + +#### Note + +`isValidSlug` (private) duplicates the slug pattern enforced by the +existing `SlugValidator` service. TODO: collapse onto `SlugValidator` +in a follow-up so the pattern lives in exactly one place. + +### Requirement: Application resolution and uniform response mapping + +Tools that operate on a single virtual app SHALL resolve the supplied +slug to an `Application` object via the `built-app-route` index in the +`openbuilt` register: the provider SHALL call +`ObjectService::searchObjectsBySlug` to locate a matching route, then +`ObjectService::find` to load the Application by its `applicationUuid`. +A missing route SHALL surface as `{ isError: true, error: 'not_found' +}`; a route present without a matching Application (orphaned index +row) SHALL surface as `{ isError: true, error: 'inconsistent_state' }`. +The compact response shape used by `listApps` SHALL include +`{ uuid, slug, name, description, status, version }`. Each MCP +response SHALL carry an OpenBuilt `source` descriptor of shape +`{ type: 'openbuilt.application', uuid, url, label }` where `url` is +a Nextcloud deep link of the form `/apps/openbuilt/builder/{slug}` +(or `/apps/openbuilt` when no slug is bound). OR entities, arrays, and +`jsonSerialize`-able objects SHALL all be accepted as input to the +mapping pipeline (`toArray`); UUIDs SHALL be extracted from the +`uuid`, `id`, `@self.uuid`, or `@self.id` fields in that fallback +order (`extractUuid`). + +**ID:** REQ-OBR-MCP-003 + +#### Scenario: Slug resolves to its Application + +- **GIVEN** a published virtual app with slug `hello-world` and a + matching `built-app-route` row pointing at its Application UUID +- **WHEN** a tool resolves the slug via `resolveApplicationBySlug` +- **THEN** the helper returns + `{ application: { ..., slug: 'hello-world', ... } }` + +#### Scenario: Missing route returns not_found + +- **WHEN** a tool resolves a slug for which no `built-app-route` row + exists +- **THEN** the helper returns `{ error: 'not_found', message: ... }` + +#### Scenario: Route without Application returns inconsistent_state + +- **GIVEN** a `built-app-route` row whose `applicationUuid` points at + an Application that has been deleted +- **WHEN** a tool resolves the slug +- **THEN** the helper returns `{ error: 'inconsistent_state', message: + ... }` + +#### Scenario: Deep link uses /apps/openbuilt/builder/{slug} + +- **WHEN** the provider calls `buildDeepLink('hello-world')` +- **THEN** the returned URL is `/apps/openbuilt/builder/hello-world` + +#### Scenario: UUID extraction falls back through @self + +- **GIVEN** an OR object array of shape + `{ '@self': { uuid: 'abc-123' } }` (no top-level `uuid` or `id`) +- **WHEN** `extractUuid` is called +- **THEN** the returned UUID is `'abc-123'` + +### Requirement: Draft-version manifest mutation isolation + +Authoring tools that mutate a virtual app +(`openbuilt.upsertSchema`, `openbuilt.upsertPage`, +`openbuilt.addWidget`, `openbuilt.upsertMenuItem`) SHALL default the +`versionSlug` argument to `development` so a misfired tool call cannot +mutate a production version. A version row SHALL be located via +`loadVersion(objectService, appSlug, versionSlug)`, which SHALL look +up the row in the `application-version` schema under +`{appSlug}-{versionSlug}` slug composition; missing rows SHALL surface +as `{ error: 'not_found' }` so the orchestrator can return a +structured error envelope. Manifest writes SHALL be performed +exclusively through `saveVersionManifest`, which SHALL deep-merge the +mutated manifest blob back onto the located version row and persist it +via `ObjectService::saveObject`; partial writes that bypass this +helper SHALL be considered a violation of this requirement. + +**ID:** REQ-OBR-MCP-004 + +#### Scenario: Authoring tools default versionSlug to development + +- **WHEN** a caller invokes `openbuilt.upsertPage` with `appSlug: + hello-world` and omits `versionSlug` +- **THEN** the mutation targets the `hello-world-development` version + row, not any production version + +#### Scenario: Unknown version returns not_found + +- **WHEN** an authoring tool resolves `loadVersion(_, 'hello-world', + 'staging')` and no `application-version` row exists with slug + `hello-world-staging` +- **THEN** the helper returns `{ error: 'not_found', message: ... }` + and the calling tool surfaces an MCP error envelope + +#### Scenario: Manifest persistence routes through saveVersionManifest + +- **WHEN** an authoring tool persists a mutated manifest +- **THEN** the persistence path is `saveVersionManifest(...)` and the + underlying `ObjectService::saveObject` call carries the merged + manifest on the located version row diff --git a/openspec/changes/archive/retrofit-2026-05-24-openbuilt-runtime-mcp/tasks.md b/openspec/changes/archive/retrofit-2026-05-24-openbuilt-runtime-mcp/tasks.md new file mode 100644 index 0000000..a9cff2e --- /dev/null +++ b/openspec/changes/archive/retrofit-2026-05-24-openbuilt-runtime-mcp/tasks.md @@ -0,0 +1,6 @@ +# Tasks + +- [x] task-1: openbuilt-runtime#REQ-OBR-MCP-001 — MCP tool-provider contract (retroactive annotation) +- [x] task-2: openbuilt-runtime#REQ-OBR-MCP-002 — Auth-gated dispatch with arg validation (retroactive annotation) +- [x] task-3: openbuilt-runtime#REQ-OBR-MCP-003 — Application resolution and uniform response mapping (retroactive annotation) +- [x] task-4: openbuilt-runtime#REQ-OBR-MCP-004 — Draft-version manifest mutation isolation (retroactive annotation) diff --git a/openspec/specs/openbuilt-runtime/spec.md b/openspec/specs/openbuilt-runtime/spec.md index 12ad61d..59f36eb 100644 --- a/openspec/specs/openbuilt-runtime/spec.md +++ b/openspec/specs/openbuilt-runtime/spec.md @@ -1,3 +1,11 @@ +--- +retrofit_extensions: + - REQ-OBR-MCP-001 + - REQ-OBR-MCP-002 + - REQ-OBR-MCP-003 + - REQ-OBR-MCP-004 +--- + # openbuilt-runtime Specification ## Purpose @@ -628,3 +636,195 @@ Live chip produces duplicate signalling. The `ob-app-card__chip--live` CSS rule - **THEN** the title heading, description paragraph, version chip, role chip, and slug chip continue to render in their expected positions and the card's click navigation to VirtualAppDetail is unaffected + +### Requirement: MCP tool-provider contract + +The OpenBuilt MCP surface SHALL be implemented by a class +(`OCA\OpenBuilt\Mcp\OpenBuiltToolProvider`) that implements +`OCA\OpenRegister\Mcp\IMcpToolProvider`. The provider SHALL declare its +host Nextcloud app id (`openbuilt`), expose a static tool catalogue of +read tools (`openbuilt.listApps`, `openbuilt.getAppManifest`) and write +tools covering virtual-app lifecycle (`openbuilt.createApp`, +`openbuilt.promoteVersion`) and draft-version authoring +(`openbuilt.upsertSchema`, `openbuilt.upsertPage`, `openbuilt.addWidget`, +`openbuilt.upsertMenuItem`), and SHALL dispatch each invocation by tool +id to the matching internal handler. Unknown tool ids SHALL return a +uniform error envelope of shape +`{ isError: true, error, message }` carrying the machine-readable code +`unknown_tool` and a human-readable message that lists the available +tool ids. + +**ID:** REQ-OBR-MCP-001 + +#### Scenario: Provider reports the OpenBuilt app id + +- **WHEN** OpenRegister's MCP orchestrator calls `getAppId()` on the + provider +- **THEN** the provider returns the string `openbuilt` + +#### Scenario: Catalogue surfaces all OpenBuilt tools + +- **WHEN** OpenRegister's MCP orchestrator calls `getTools()` +- **THEN** the returned array contains the eight tool descriptors + (`openbuilt.listApps`, `openbuilt.getAppManifest`, + `openbuilt.createApp`, `openbuilt.promoteVersion`, + `openbuilt.upsertSchema`, `openbuilt.upsertPage`, + `openbuilt.addWidget`, `openbuilt.upsertMenuItem`), each with an + `inputSchema` of `type: object` + +#### Scenario: Unknown tool id returns a structured error + +- **WHEN** OpenRegister's MCP orchestrator calls + `invokeTool('openbuilt.nope', [])` +- **THEN** the response is `{ isError: true, error: 'unknown_tool', + message: ... }` and `message` lists the available tool ids + +### Requirement: Auth-gated dispatch with arg validation + +Every MCP tool exposed by this provider SHALL require an authenticated +Nextcloud session. The provider SHALL resolve the active user via +`IUserSession`; if no user is signed in (or the user UID is empty), the +handler SHALL short-circuit with an `{ isError: true, error: +'forbidden', message }` envelope before performing any read or write. +Read-tool argument shape SHALL be validated up-front — `listApps` +SHALL clamp `limit` to the range 1..50 and SHALL reject any +`statusFilter` outside the closed set `{any, draft, published, +archived}` with `{ isError: true, error: 'invalid_arguments' }`. Slug +arguments accepted by the write surface SHALL conform to a shared +pattern (lowercase alphanumeric, hyphen-separated, 2..48 chars, +matching `^[a-z0-9][a-z0-9-]*[a-z0-9]$`). A public `isAdmin($userId)` +helper SHALL delegate to `IGroupManager::isAdmin` so callers can probe +admin posture without re-implementing the check. + +**ID:** REQ-OBR-MCP-002 + +#### Scenario: Unauthenticated caller is rejected + +- **WHEN** the MCP orchestrator invokes any OpenBuilt tool with no + active `IUserSession` user +- **THEN** the response is `{ isError: true, error: 'forbidden', ... }` + and no OpenRegister read/write is attempted + +#### Scenario: listApps rejects an out-of-range limit + +- **WHEN** an authenticated caller invokes `openbuilt.listApps` with + `limit: 0` (or `limit: 51`) +- **THEN** the response is `{ isError: true, error: + 'invalid_arguments', message: "Invalid limit 0." }` + +#### Scenario: listApps rejects an unknown statusFilter + +- **WHEN** an authenticated caller invokes `openbuilt.listApps` with + `statusFilter: 'weird'` +- **THEN** the response is `{ isError: true, error: + 'invalid_arguments', message: "Invalid statusFilter 'weird'." }` + +#### Scenario: isAdmin reports admin membership + +- **WHEN** a caller queries `isAdmin('alice')` and Nextcloud's group + manager reports Alice in the admin group +- **THEN** the helper returns `true` + +#### Note + +`isValidSlug` (private) duplicates the slug pattern enforced by the +existing `SlugValidator` service. TODO: collapse onto `SlugValidator` +in a follow-up so the pattern lives in exactly one place. + +### Requirement: Application resolution and uniform response mapping + +Tools that operate on a single virtual app SHALL resolve the supplied +slug to an `Application` object via the `built-app-route` index in the +`openbuilt` register: the provider SHALL call +`ObjectService::searchObjectsBySlug` to locate a matching route, then +`ObjectService::find` to load the Application by its `applicationUuid`. +A missing route SHALL surface as `{ isError: true, error: 'not_found' +}`; a route present without a matching Application (orphaned index +row) SHALL surface as `{ isError: true, error: 'inconsistent_state' }`. +The compact response shape used by `listApps` SHALL include +`{ uuid, slug, name, description, status, version }`. Each MCP +response SHALL carry an OpenBuilt `source` descriptor of shape +`{ type: 'openbuilt.application', uuid, url, label }` where `url` is +a Nextcloud deep link of the form `/apps/openbuilt/builder/{slug}` +(or `/apps/openbuilt` when no slug is bound). OR entities, arrays, and +`jsonSerialize`-able objects SHALL all be accepted as input to the +mapping pipeline (`toArray`); UUIDs SHALL be extracted from the +`uuid`, `id`, `@self.uuid`, or `@self.id` fields in that fallback +order (`extractUuid`). + +**ID:** REQ-OBR-MCP-003 + +#### Scenario: Slug resolves to its Application + +- **GIVEN** a published virtual app with slug `hello-world` and a + matching `built-app-route` row pointing at its Application UUID +- **WHEN** a tool resolves the slug via `resolveApplicationBySlug` +- **THEN** the helper returns + `{ application: { ..., slug: 'hello-world', ... } }` + +#### Scenario: Missing route returns not_found + +- **WHEN** a tool resolves a slug for which no `built-app-route` row + exists +- **THEN** the helper returns `{ error: 'not_found', message: ... }` + +#### Scenario: Route without Application returns inconsistent_state + +- **GIVEN** a `built-app-route` row whose `applicationUuid` points at + an Application that has been deleted +- **WHEN** a tool resolves the slug +- **THEN** the helper returns `{ error: 'inconsistent_state', message: + ... }` + +#### Scenario: Deep link uses /apps/openbuilt/builder/{slug} + +- **WHEN** the provider calls `buildDeepLink('hello-world')` +- **THEN** the returned URL is `/apps/openbuilt/builder/hello-world` + +#### Scenario: UUID extraction falls back through @self + +- **GIVEN** an OR object array of shape + `{ '@self': { uuid: 'abc-123' } }` (no top-level `uuid` or `id`) +- **WHEN** `extractUuid` is called +- **THEN** the returned UUID is `'abc-123'` + +### Requirement: Draft-version manifest mutation isolation + +Authoring tools that mutate a virtual app +(`openbuilt.upsertSchema`, `openbuilt.upsertPage`, +`openbuilt.addWidget`, `openbuilt.upsertMenuItem`) SHALL default the +`versionSlug` argument to `development` so a misfired tool call cannot +mutate a production version. A version row SHALL be located via +`loadVersion(objectService, appSlug, versionSlug)`, which SHALL look +up the row in the `application-version` schema under +`{appSlug}-{versionSlug}` slug composition; missing rows SHALL surface +as `{ error: 'not_found' }` so the orchestrator can return a +structured error envelope. Manifest writes SHALL be performed +exclusively through `saveVersionManifest`, which SHALL deep-merge the +mutated manifest blob back onto the located version row and persist it +via `ObjectService::saveObject`; partial writes that bypass this +helper SHALL be considered a violation of this requirement. + +**ID:** REQ-OBR-MCP-004 + +#### Scenario: Authoring tools default versionSlug to development + +- **WHEN** a caller invokes `openbuilt.upsertPage` with `appSlug: + hello-world` and omits `versionSlug` +- **THEN** the mutation targets the `hello-world-development` version + row, not any production version + +#### Scenario: Unknown version returns not_found + +- **WHEN** an authoring tool resolves `loadVersion(_, 'hello-world', + 'staging')` and no `application-version` row exists with slug + `hello-world-staging` +- **THEN** the helper returns `{ error: 'not_found', message: ... }` + and the calling tool surfaces an MCP error envelope + +#### Scenario: Manifest persistence routes through saveVersionManifest + +- **WHEN** an authoring tool persists a mutated manifest +- **THEN** the persistence path is `saveVersionManifest(...)` and the + underlying `ObjectService::saveObject` call carries the merged + manifest on the located version row