Search by

MCP server integration for Orbis CMS.

Package info

gitlab.com/orbis-cms/mcp

Issues

pkg:composer/orbis-cms/mcp

Statistics

Installs: 58

Dependents: 0

Suggesters: 0

Stars: 0

0.0.19 2026-10-20 01:34 UTC

This package is auto-updated.

Last update: 2026-10-19 22:35:04 UTC


README

MCP transport for Orbis CMS, built on Laravel MCP. It does not introduce an authoring layer: content creation and updates continue to use Core's ContentCreator.

Requirements

  • PHP 8.3 or higher
  • Laravel 13
  • orbis-cms/core 0.0
  • laravel/mcp 1.0
  • laravel/passport 13.0
  • orchid/platform 14.0 (already required by orbis-cms/core)

Installation

composer require orbis-cms/mcp
php artisan migrate

Register the provided server in routes/ai.php:

use Laravel\Mcp\Facades\Mcp;
use OrbisCms\Mcp\Servers\OrbisCmsServer;

Mcp::web('/mcp/orbis', OrbisCmsServer::class)->middleware('mcp.auth');

Authentication

The package owns MCP authentication end to end; the host application does not configure a guard itself. On boot, McpServiceProvider:

  • registers a dedicated mcp guard (Passport's token guard, kept separate from the application's own api guard so MCP scoping never leaks into it);
  • publishes the OAuth 2.0 discovery, dynamic client registration, and authorization endpoints (Mcp::oauthRoutes()) that Claude, ChatGPT, and other MCP hosts use to connect as a remote connector;
  • defines an mcp.auth middleware group that requires a valid mcp guard token carrying the mcp:use scope.

Apply ->middleware('mcp.auth') to the server route, as shown above, and nothing else. There is no per-content-type or per-action authorization: any principal holding a token with the mcp:use scope has full access to every tool and resource registered on the server.

The Eloquent model backing the mcp guard's provider (auth.providers.<name>.model) must use Passport's Laravel\Passport\HasApiTokens trait. Without it, Passport's token guard throws a BadMethodCallException from withAccessToken() on every authenticated request — this is a host-side model requirement, not something the package can enforce or recover from.

mcp.auth always returns a bare 401/403 JSON response on failure, regardless of how the host application configures exception rendering. Laravel's exception handler resolves the JSON-vs-redirect decision for AuthenticationException through two app-wide, guard-unaware hooks — Handler::shouldRenderJsonWhen() and AuthenticationException::redirectUsing() (set via bootstrap/app.php's ->shouldRenderJsonWhen()/->redirectGuestsTo()) — and either one, configured by the host for its own unrelated routes, would otherwise silently turn an MCP 401 into a 302 redirect to an HTML login page, breaking the WWW-Authenticate discovery handshake MCP clients rely on to start OAuth. EnsureMcpFailuresAreJson catches auth failures before they reach that global handler, so this holds no matter what the host configures.

The MCP agent and the "Authorize agent" flow

Every access token is issued to one dedicated MCP agent — a regular application user record, resolved by McpAgentResolver. No human ever logs in as it. Two ways to identify it, configured under orbis-mcp.agent:

  • user_id (default null) — point at an existing user explicitly; that record must already exist and is used as-is, so a misconfigured ID fails loudly instead of silently creating a duplicate.
  • email (default null, falling back to mcp-agent@<app host>) — used only when user_id is null: the user is found by that email or created automatically on first use if it does not exist yet.

An Orchid operator authorizes a connector (ChatGPT, Claude, ...) on the agent's behalf instead. McpServiceProvider replaces Passport's own GET /oauth/authorize step with McpAuthorizeController, which:

  1. requires the visitor to hold the orbis-cms-mcp.authorize Orchid permission (grant it to whichever roles should be allowed to connect agents, from the standard Orchid roles screen);
  2. shows a single "Authorize agent" / "Deny" confirmation naming the requesting client, with no per-scope grant step;
  3. on approval, issues the token to the MCP agent, not to the operator who clicked the button.

Passport's stock /oauth/authorize POST (approve) and DELETE (deny) endpoints, and the /oauth/token exchange, are untouched — only the initial human-facing consent step is replaced.

Direct access tokens

For integrations that can send a configurable Authorization header, Orchid offers MCP access tokens at /admin/mcp/tokens. Operators with the orbis-cms-mcp.authorize permission can create and revoke them. A token is issued to the dedicated MCP agent with only the mcp:use scope, shown after creation, and used as Authorization: Bearer <token>.

The first direct token automatically creates Passport's personal access client for the MCP user provider if it does not already exist. The token screen only lists and can revoke these personal access tokens; OAuth connections continue to be managed through their own flow and cannot be affected there.

Content definitions

An application Contentable model is exposed only when it implements HasMcpDefinition. Its fields live beside the model and use the model attribute name directly. A field is ReadOnly by default; set FieldAccess::ReadWrite explicitly for data that MCP may accept. A field omitted from the definition is not exposed to MCP.

use OrbisCms\Mcp\Contracts\HasMcpDefinition;
use OrbisCms\Mcp\Definitions\FieldDefinition;
use OrbisCms\Mcp\Definitions\FieldAccess;
use OrbisCms\Core\Models\Contracts\Contentable;
use Illuminate\JsonSchema\JsonSchema;
use Orchid\Filters\Filterable;
use Orchid\Filters\Types\Like;
use Orchid\Filters\Types\WhereDateStartEnd;

class Article extends Model implements Contentable, HasMcpDefinition
{
    use Filterable;

    protected $allowedFilters = [
        'title' => Like::class,
        'created_at' => WhereDateStartEnd::class,
    ];

    public static function mcpDescription(): string
    {
        return 'Editorial article.';
    }

    public static function mcpFields(): array
    {
        return [
            new FieldDefinition(
                name: 'title',
                schema: JsonSchema::string()
                    ->title('Article title')
                    ->description('Article title.')
                    ->max(255)
                    ->required(),
                example: 'Introducing MCP',
                access: FieldAccess::ReadWrite,
            ),
            new FieldDefinition(
                name: 'body',
                schema: JsonSchema::string()->description('Article body in Markdown.'),
                access: FieldAccess::ReadWrite,
            ),
        ];
    }
}

SchemaAdapter builds one schema per content type covering both what a caller may write and what it reads back, instead of separate input/output schemas: ReadOnly fields carry readOnly: true and are excluded from required (which therefore means "required to write"). A Pageable content type additionally includes the related Core Page as a page object in that same schema; its URI and publication status are read-only. Content types that are only Contentable work without a Page.

To make a content type filterable, add Orchid's Filterable trait to its model and declare the allowed fields and filter classes in allowedFilters, as shown above. For MCP-only filters — ones that don't belong in the admin screen's filter set, such as a filter reaching across a relation — implement HasMcpFilters::mcpFilters() on the model instead; it does not require the Filterable trait. MCP-only filters may override an allowedFilters entry with the same key. orbis.describe_content_type and the content-type schema resource then return a filters map derived from both sources. Each entry describes the filter's behavior and includes its registered value schema. A filter without a registered definition raises an error, so custom filters must be registered before the definition is read. Models with neither source return an empty map.

Applications can describe a custom Orchid filter by registering its value schema during boot:

use OrbisCms\Mcp\Definitions\FilterValueSchemaRegistry;

app(FilterValueSchemaRegistry::class)->register(CustomFilter::class, [
    'type' => 'object',
    'properties' => ['query' => ['type' => 'string']],
], 'Matches records using a custom query.');

The mutable input and returned content share one shape: model data is nested under attributes, and a Pageable type may additionally carry a page relation. This avoids collisions between model and Page fields such as title.

Laravel validation on creationRules() and updateRules() remains authoritative. Before a call reaches ContentCreator, this package rejects attributes not present in the definition or marked read-only.

MCP API

Resources:

  • orbis://content-types
  • orbis://content-types/{alias}/schema

Tools:

  • orbis.list_content_types
  • orbis.describe_content_type (full schema for a type alias)
  • orbis.attach_image (download an image from a public HTTP(S) URL and add it to an image-capable content record)
  • orbis.upload_chatgpt_image (attach an image supplied through ChatGPT file input in one tool call)
  • orbis.prepare_image_upload (issue a one-time signed upload link for a content record)
  • orbis.search_pages
  • orbis.list_content
  • orbis.get_content (by type alias and content ID)
  • orbis.get_page (by Page ID or canonical URI)
  • orbis.create_content
  • orbis.update_content

orbis.create_content creates a Page only when page data is supplied for a Pageable type; that Page is always a draft. Neither create nor update accepts a publication status. Draft mutations return a signed preview URL; publication remains a human CMS operation.

In ChatGPT, use orbis.upload_chatgpt_image with type, id, and an image file. The tool declares _meta["openai/fileParams"], so ChatGPT passes a temporary download URL for the attached file. The server downloads and validates the image (JPEG, PNG, WebP, or GIF up to 10 MiB), then attaches it in the content model's image group. The chat environment does not need to make an HTTP POST or access the MCP bearer token.

For clients with multipart HTTP access, call orbis.prepare_image_upload with type and id. It returns a one-time uploadUrl valid for one hour. Send a multipart POST with a file in the image field, for example curl -F 'image=@/path/to/image.png' "$uploadUrl". The signed link authorizes the upload without exposing the MCP bearer token. The endpoint validates JPEG, PNG, WebP, or GIF up to 10 MiB, attaches the image immediately, and returns contentId, imageId, and group. An unused link expires without storing a file.

orbis.list_content also accepts an optional filters object keyed by the model's allowedFilters and MCP-only filter fields. Use orbis.describe_content_type for the available keys, filter descriptions, and value shapes. For the Article example above:

{"type":"article","filters":{"title":"Laravel","created_at":{"start":"2026-01-01"}},"limit":20}

Unknown filter keys are rejected; values are passed to their Orchid filter classes. To fetch the next page, pass the returned nextCursor as cursor with the same type and filters. Omit cursor when changing filters so pagination starts from the new result set.

The server also exposes Laravel MCP's search_tools and execute_tools alongside the directly callable tools. Use search_tools to inspect catalog tool schemas, then pass independent calls to execute_tools as {"calls":[{"name":"orbis.get_content","arguments":{"type":"article","id":1}},{"name":"orbis.get_content","arguments":{"type":"article","id":2}}]}. Calls run sequentially and stop on the first error; earlier successful mutations are not rolled back. Laravel MCP's mcp.tool_search.max_tool_calls setting limits the number of calls per batch (10 by default).

orbis.update_content uses PATCH semantics: supply only the writable content attributes or Page metadata that should change. Only supplied fields are validated and updated; all other fields retain their existing values. When page data creates a previously absent Page, its normal creation validation still applies.

Limiting response size

orbis.list_content, orbis.get_content and orbis.get_page accept an optional fields array of attribute names. When given, only those attributes are returned in attributes; when omitted (or empty), all of them are. id and type are always present at the top level, and a Pageable record's page object is not affected by fields. An unknown name is rejected with an Unknown fields error; orbis.describe_content_type lists the available names.

orbis.list_content also truncates long values: a string attribute longer than orbis-mcp.list.hidden_length_threshold characters (default 200, ORBIS_MCP_LIST_HIDDEN_LENGTH) is omitted from attributes and named in that record's hiddenAttributes, which keeps a page of results compact even when a content type has a long HTML/Markdown body. Truncation applies to the selected fields and is on by default (orbis-mcp.list.truncate, ORBIS_MCP_LIST_TRUNCATE); pass truncate: false to get full values for one call. orbis.get_content and orbis.get_page never truncate.

Tool results carry the payload in structuredContent only; content holds a one-line note. Set orbis-mcp.response.mirror_json (ORBIS_MCP_MIRROR_JSON=true) to repeat the JSON in content for clients that ignore structuredContent (this doubles the tokens per response). Resources always return the full JSON in content.

Extending the server

The McpRegistry is independent of Core's ContentRegistry. The package service provider registers the standard CMS primitives in it. Add application-specific primitives to that same registry in routes/ai.php:

use OrbisCms\Mcp\Facades\OrbisMcp;

OrbisMcp::tool(SyncCatalogTool::class);
OrbisMcp::resource(ProductGuideResource::class);

The registry accepts only subclasses of Laravel MCP's Tool and Resource, ignores duplicate registrations, and is read by OrbisCmsServer during MCP server boot. This keeps application tools on the same endpoint and behind the same middleware as standard CMS tools.

Development

The test suite has two layers:

  • tests/Unit for isolated registry, definition, schema, and transformation tests;
  • tests/Feature for Laravel container, database, MCP transport, and Orbis CMS integration tests.

Run tests and style checks with:

composer test
composer pint:test

License

This package is open-sourced software licensed under the MIT license.