Start Here
Hello World Extension
This tutorial builds the smallest useful extension: a local package that returns a greeting from a read-only API action. The working reference is app/extensions/myron-sample/.
Goal
Create a local extension that registers:
- vendor:
my_vendor - action URI:
/api/v1/x/my_vendor/hello - action domain:
x.my_vendor.hello - method:
GET - scope:
content.read - MCP exposure:
exposeToMcp: true
Prerequisites
- You are working in the MyronCMS repository.
- You can create files under
app/extensions/. - The install has extension scope grant tables available.
- The operator can register and enable the extension with
content.read.
Human Path
Create the extension directory:
app/extensions/my_vendor/
manifest.json
bootstrap.php
actions/MyVendorHelloAction.php
Create manifest.json:
{
"vendor": "my_vendor",
"name": "My Vendor Hello",
"version": "0.1.0",
"requiredScopes": ["content.read"],
"hasMigrations": false,
"adminRoutes": []
}
Create bootstrap.php:
<?php
declare(strict_types=1);
require_once __DIR__ . '/actions/MyVendorHelloAction.php';
return [
new MyVendorHelloAction($sdk),
];
Create actions/MyVendorHelloAction.php:
<?php
declare(strict_types=1);
final class MyVendorHelloAction implements MyronAction
{
public function __construct(private readonly MyronExtensionSDK $sdk)
{
}
public function metadata(): MyronActionMetadata
{
return new MyronActionMetadata(
uri: '/api/v1/x/my_vendor/hello',
method: 'GET',
domain: 'x.my_vendor.hello',
resource: 'hello',
verb: 'get',
inputSchema: ['type' => 'object', 'properties' => []],
outputSchema: [
'type' => 'object',
'properties' => [
'message' => ['type' => 'string'],
'siteType' => ['type' => 'string'],
],
],
requiredScope: MyronApiScopes::CONTENT_READ,
summary: 'Return a hello-world extension greeting.',
sideEffects: [],
preconditions: ['The my_vendor extension is enabled and granted content.read.'],
postconditions: [],
audienceClass: 3,
exposeToMcp: true,
);
}
public function execute(array $input, MyronActionCallerContext $caller): MyronActionResult
{
return MyronActionResult::ok([
'message' => 'Hello from my_vendor extension',
'siteType' => $this->sdk->getSiteType(),
]);
}
}
Register and enable the extension from the Extensions admin surface, granting content.read. Until that happens, the loader skips the extension when grants are required.
AI Path
An AI coding agent can perform the same local file-authoring steps in the repository. It must not claim the running MCP client created the PHP files.
After the files exist and the operator registers/enables/grants the extension, an installed API or MCP client can verify the result:
- API path:
GET /api/v1/x/my_vendor/hello - MCP discovery:
tools/list - MCP call:
tools/callwith tool namex_my_vendor_hello_hello_get
The MCP tool name comes from the default tool-name mapping: {domain}_{resource}_{verb}, normalized for strict clients.
Scopes
The manifest declares content.read, and the action metadata requires MyronApiScopes::CONTENT_READ. Both gates matter:
- the extension grant must include
content.read, - the API or MCP caller must also hold
content.read.
Artifacts
The local artifacts are:
app/extensions/my_vendor/manifest.jsonapp/extensions/my_vendor/bootstrap.phpapp/extensions/my_vendor/actions/MyVendorHelloAction.php
The runtime entrypoints are:
GET /api/v1/x/my_vendor/hello- MCP
tools/list - MCP
tools/call
Safety Boundary
Keep the extension namespaced. ActionRegistry::registerExtension() rejects an action if the URI does not start with /api/v1/x/my_vendor/, if the domain does not start with x.my_vendor., or if either schema is an empty array.
Do not use this tutorial to mutate core tables, add public render hooks, inject core admin rail sections, or bypass extension scope grants.
Verification
Run the local foundation check:
php app/tests/verify-extension-system-foundation.php
Then verify the action with the API using a bearer token that holds content.read:
curl -sS \
-H "Authorization: Bearer ${MYRON_TOKEN}" \
"https://example.test/api/v1/x/my_vendor/hello"
A successful response uses the canonical action envelope and contains a result with the greeting message.
For MCP, call tools/list with the same authorization context and confirm x_my_vendor_hello_hello_get appears. Then call that tool with an empty argument object.
Failure Modes
| Failure | Likely surface |
|---|---|
| Vendor mismatch | ExtensionManifest::fromFile() rejects the manifest. |
| Unknown scope | Manifest scope normalization rejects the manifest. |
| Extension not registered | Loader skips the extension. |
| Extension disabled | Loader does not register its actions. |
| Missing grant | Loader reports an ungranted required scope. |
| Wrong URI prefix | ActionRegistry::registerExtension() rejects the action. |
| Wrong domain prefix | ActionRegistry::registerExtension() rejects the action. |
| Empty schema | ActionRegistry::registerExtension() rejects the action. |
| Missing bearer token | API dispatch returns auth_required for private actions. |
| Insufficient caller scope | API dispatch returns scope_insufficient; MCP omits or rejects the tool depending on the call path. |
exposeToMcp: false | API may still exist, but MCP tools/list will not show the action. |