MyronCMS Developers Hub Docs sandbox review

Build Extensions

Write A Write Action

A write action is a MyronAction that changes state or triggers a side effect. The metadata must make that risk visible before the dispatcher runs the action and before an AI client decides whether to call it.

Use write actions for bounded extension mutations: create a vendor-owned row, update an extension setting, enqueue extension work, or delete a vendor-owned resource.

Method Choice

MethodUse
POSTCreate a resource or run a non-idempotent command.
PATCHMutate part of an existing resource.
DELETERemove a resource.

Do not use PUT for extension action examples. Current action conventions reserve mutations for PATCH and creation or commands for POST.

Human Path

Declare the mutation clearly in metadata:

final class MyVendorNoteCreateAction implements MyronAction
{
    public function metadata(): MyronActionMetadata
    {
        return new MyronActionMetadata(
            uri: '/api/v1/x/my_vendor/notes',
            method: 'POST',
            domain: 'x.my_vendor.notes',
            resource: 'note',
            verb: 'create',
            inputSchema: [
                'type' => 'object',
                'properties' => [
                    'title' => ['type' => 'string'],
                    'body' => ['type' => 'string'],
                ],
                'required' => ['title', 'body'],
            ],
            outputSchema: [
                'type' => 'object',
                'properties' => [
                    'noteId' => ['type' => 'string'],
                ],
            ],
            requiredScope: MyronApiScopes::CONTENT_WRITE,
            summary: 'Create a my_vendor note.',
            sideEffects: ['inserts my_vendor_notes row'],
            preconditions: [
                'The my_vendor extension is enabled and granted content.write.',
                'title and body are non-empty strings.',
            ],
            postconditions: ['A note row exists in my_vendor_notes.'],
            audienceClass: 3,
            exposeToMcp: true,
        );
    }

    public function execute(array $input, MyronActionCallerContext $caller): MyronActionResult
    {
        $title = trim((string) ($input['title'] ?? ''));
        $body = trim((string) ($input['body'] ?? ''));

        if ($title === '' || $body === '') {
            return MyronActionResult::error('precondition_failed', 'title and body must be non-empty.');
        }

        $noteId = 'note_' . bin2hex(random_bytes(8));
        // Insert into a vendor-owned table such as my_vendor_notes.

        return MyronActionResult::ok(['noteId' => $noteId]);
    }
}

The dispatcher validates required schema fields and simple declared types before execute() runs. The action should still validate business preconditions that JSON Schema cannot express.

AI Path

An AI coding agent may author the action locally and run verification. It should make side effects explicit in code review notes and docs.

An installed MCP client may call the resulting tool only when all gates pass:

  • extension is registered,
  • extension is enabled,
  • extension grant includes the manifest scope,
  • action metadata requiredScope is held by the caller,
  • exposeToMcp is true,
  • action preconditions are satisfied.

Write actions are not hidden from MCP just because they write. MyronCMS uses scopes as the primary trust gate. Use exposeToMcp:false only when the action fits a documented hidden category such as recursive trust control, Tier-3 identity/admin policy, visitor-facing behavior, internal dispatch, or an unresolved parity gap.

Scopes

Choose the narrowest current base scope that matches the mutation. Extension manifests are limited to base scopes such as content.write, design.write, and administration.write.

For the note example:

{
  "requiredScopes": ["content.write"]
}

The action should also declare:

requiredScope: MyronApiScopes::CONTENT_WRITE

Artifacts

Local files:

  • app/extensions/my_vendor/actions/MyVendorNoteCreateAction.php
  • app/extensions/my_vendor/bootstrap.php
  • app/extensions/my_vendor/manifest.json
  • optional isolated migration for my_vendor_notes

Runtime surfaces:

  • POST /api/v1/x/my_vendor/notes
  • MCP tools/list
  • MCP tools/call

Safety Boundary

Write actions must not mutate core tables directly. If the action needs storage, keep it inside the extension's vendor boundary, such as {vendor}_* tables created by isolated migrations.

Do not document broad-scope fixes as remediation for a failing call. If a caller lacks scope, the safe answer is to use an appropriately granted caller or have an operator review the grant. An MCP client must not grant itself trust.

Do not swallow precondition failures. Return a declared error with MyronActionResult::error() so direct API and MCP clients receive stable failure text.

Verification

Run:

php app/tests/verify-extension-system-foundation.php

Verify direct API behavior with a scoped caller:

curl -sS \
  -X POST \
  -H "Authorization: Bearer ${MYRON_TOKEN}" \
  -H "Content-Type: application/json" \
  --data '{"title":"Release note","body":"Created by an extension action."}' \
  "https://example.test/api/v1/x/my_vendor/notes"

Then verify failure behavior:

  • omit title or body and confirm input_invalid or precondition_failed,
  • call with a read-only token and confirm scope_insufficient,
  • inspect MCP tools/list with a scoped and under-scoped caller.

Failure Modes

FailureSourceSafe fix
input_invalidDispatcher schema validation.Send all required fields with declared types.
scope_insufficientDispatcher caller scope check.Use a caller granted the action scope.
precondition_failedAction-declared business guard.Satisfy the named precondition before retrying.
business_rule_failedAction-declared domain failure.Fix the domain state rather than bypassing validation.
Tool absent from MCPexposeToMcp:false, missing load, or missing caller scope.Confirm exposure rationale and caller grants.
Uncaught exceptionAction threw instead of returning a declared error.Catch known domain failures and return MyronActionResult::error().