Skip to content

MCP Server — Adding Tools ​

This page explains how to add a new tool to the mcp plugin's Model-Context-Protocol server and wire it to the canonical API. The server lets LLM clients (Claude Code, Claude Desktop, Cursor) drive the CMS over HTTP with a Bearer token. A tool is a PHP method that takes typed parameters (turned into a JSON schema by the SDK) and returns an array.

The golden rule: a write tool never re-implements model logic. It calls the real backend/* endpoint over an authenticated loopback so the canonical validation, draft handling, ID generation, and CSS recompile run unchanged.

Directory structure ​

_public/extensions/core/backend/mcp/
├── bootstrap.php                       # mcp_BackendPlugin — registers backend/mcp + backend/mcptokens
├── composer.json                       # plugin-local: mcp/sdk, PSR-4 Newmeta\Mcp\Tools\ → tools/
├── vendor/                             # plugin-local Composer install (autoload.php)
├── api/
│   ├── backend/mcp/model.php           # backend_mcp — boots the SDK server, registers every tool
│   └── backend/mcptokens/model.php     # token CRUD + the scope whitelist (const SCOPES)
├── lib/
│   ├── McpAuth.php                     # Bearer-token validation + service-session context
│   ├── McpBridge.php                   # requireScope() / requireAnyScope() / audit() / baseLanguage()
│   └── McpHttpBridge.php               # call() / upload() — authenticated HTTP loopback to backend/*
└── tools/
    ├── PingTool.php                    # health check (read)
    ├── DesignTools.php                 # read tool (direct query())
    ├── DesignWriteTools.php            # write tool (loopback to backend/design)
    ├── PagebuilderWriteTools.php       # draft-first write tools
    ├── RedirectTools.php               # direct write via backend/item
    ├── PublishTools.php                # the only live page promotion (publish scope)
    └── …                               # one class per tool group

The Composer setup is plugin-local — its own composer.json and vendor/ — because the CMS core is require_once-based. vendor/autoload.php is loaded inside the backend/mcp endpoint, not globally.

Architecture in one diagram ​

The endpoint authenticates the Bearer token, sets a backend service session, builds the SDK server with every registered tool, and dispatches the JSON-RPC request via StreamableHttpTransport. A write tool gates on a scope, then calls the canonical endpoint over a loopback that re-uses the same session:

LLM client (Claude / Cursor)
   │  POST /api/backend/mcp   Authorization: Bearer nscms_mcp_…
   ▼
backend_mcp::apiAction()                            (api/backend/mcp/model.php)
   ├─ McpAuth::authenticate()      → mcp_tokens (SHA-256), scopes, tenant_uuid
   │                                 sets $_SESSION[backend_loggedin|backend_superadmin|mcp_session]
   ├─ require vendor/autoload.php   (plugin-local SDK)
   ├─ Mcp\Server::builder()->setServerInfo()->setSession(FileSessionStore)
   │     ->addTool([Tool::class,'method'], name:, description:) …  (one per tool)
   └─ $server->run(StreamableHttpTransport)  → dispatch to the matched tool method
          │
          ▼
   YourTool::yourMethod($typedArgs)            (tools/YourTool.php)
     ├─ McpBridge::requireScope('content')     ← scope gate (throws if missing)
     ├─ McpHttpBridge::call('backend/x', …)    ← authenticated loopback (same session cookie)
     │        │  curl https://{host}/api/backend/x  Cookie: PHPSESSID=…
     │        ▼
     │   backend_x::apiAction()  → canonical validation / draft / recompile / insert_id()
     ├─ McpBridge::audit('mcp.tool.…')
     └─ return ['ok' => true, …]   (try/catch → ['ok' => false, 'error' => …])

Key properties of the endpoint (backend_mcp):

PropertyValueWhy
$publicMethods['GET', 'POST', 'DELETE']The SDK transport needs all three; the Bearer token is the real gate
$skipOriginChecktrueRemote CLI clients have no matching Origin/Cookie — auth is the token, not the origin

The Bearer token is the gate, not the origin

$skipOriginCheck = true lets the request through apiBaseController. Authorization is enforced only by McpAuth::authenticate() plus the per-tool scope check. Never skip requireScope() in a write tool because "the endpoint is already protected" — without it any read-scoped token could write.

Scopes ​

McpAuth resolves the token's scopes into the service context. Tools gate on them via McpBridge. The whitelist lives in api/backend/mcptokens/model.php as private const SCOPES:

ScopeGrants
readRead tools (catalogs, structure, settings, media search)
designsave_design_less (live)
contentPagebuilder draft writes, widget content, redirects, SEO meta, settings
menuMenu item create/update
mediaMedia folder/file create, rename, upload
publishpublish_page — the only live page promotion

McpAuth::hasScope() treats a * scope as "all", except publish_page, which checks for the literal publish scope and ignores * on purpose.

Loopback vs. direct query ​

Tool kindMechanismExample
Readquery() / fetch_assoc() directlyDesignTools::getDesignTokens() reads website.custom_less
Write (canonical endpoint)McpHttpBridge::call($endpoint, $method, $payload, $query)DesignWriteTools → backend/design save_less
Write (content_construct=table)McpHttpBridge::call('backend/item', …)RedirectTools → backend/item with {table, data} / {table, id, data}
Upload (multipart)McpHttpBridge::upload(…)media file upload

McpHttpBridge::call() runs session_write_close() first so the loopback request can read the same backend_loggedin session, then curls https://{host}/api/{endpoint} with the current session cookie. On a >= 400 response it throws a RuntimeException carrying the endpoint's error — no partial write reaches the client.

Draft-first ​

Pagebuilder writes are draft-first. pagebuilder_add_row always calls init_draft (idempotent — ensures a published baseline) before editing, so the live page stays untouched until an operator publishes. Only publish_page promotes a draft to live, and it requires the literal publish scope.

Add a new tool — step by step ​

The example adds a set_page_noindex tool that flips a page's noindex SEO flag through the canonical backend/domain endpoint (illustrative — adapt the endpoint/action to your real target).

1. Create the Tool class / method ​

Tool classes live in tools/, namespace Newmeta\Mcp\Tools (PSR-4, plugin-local autoloader). Each method carries an #[McpTool(...)] attribute. Typed parameters become the JSON schema the LLM sees; the return value is an array. Always wrap the body in try/catch and return a structured {ok: false, error} on failure.

php
<?php

namespace Newmeta\Mcp\Tools;

use Mcp\Capability\Attribute\McpTool;

/**
 * Write-tool example: flip a page's noindex flag (draft, via loopback).
 */
class NoindexTools
{
    #[McpTool(
        name: 'set_page_noindex',
        description: 'Set or clear the noindex SEO flag of a page (draft). page_id from list_pages; noindex=true hides the page from search engines once published.'
    )]
    public function setPageNoindex(int $page_id, bool $noindex): array
    {
        try {
            // 2a. Scope gate — throws if the token lacks the scope.
            \McpBridge::requireScope('content');

            if ($page_id <= 0) {
                throw new \RuntimeException('page_id is required.');
            }

            // 2b. Reuse canonical logic via the authenticated loopback.
            $res = \McpHttpBridge::call('backend/domain', 'PATCH', [
                'action'  => 'update_seo_meta',
                'page_id' => $page_id,
                'noindex' => $noindex ? 1 : 0,
                'lang'    => \McpBridge::baseLanguage(),
            ]);

            // 2c. Audit the call (best effort, INFO).
            \McpBridge::audit('mcp.tool.set_page_noindex', ['page_id' => $page_id]);

            return ['ok' => true, 'result' => $res];
        } catch (\Throwable $e) {
            // Structured error — the SDK would otherwise hide the message
            // behind a generic JSON-RPC -32603.
            return ['ok' => false, 'error' => $e->getMessage(), 'error_class' => get_class($e)];
        }
    }
}

Real-world references: tools/DesignWriteTools.php (single loopback write), tools/RedirectTools.php (shared guard() + backend/item), tools/PagebuilderWriteTools.php (draft-first).

Shared guard() for multi-method classes

When a class has several write methods that share one scope, factor the requireScope + audit + try/catch into a private guard(callable $fn) helper, as RedirectTools and PagebuilderWriteTools do. Each method then just returns $this->guard(fn () => …).

2. Choose loopback or direct query ​

GoalDo this
Write through a dedicated endpoint (design, pagebuilder, SEO)`McpHttpBridge::call('backend/{x}', 'POST'
Write a content_construct=table record (redirects, settings)McpHttpBridge::call('backend/item', 'POST', ['table' => …, 'data' => …])
Readquery() / fetch_assoc() / fetch_all() directly, gate with requireAnyScope(['read', '{domain}'])
Upload a fileMcpHttpBridge::upload($endpoint, $query, $fields, $fileField, $filePath, $fileName)

Match the HTTP method the target endpoint expects (backend/item uses POST to create, PATCH to update). For models that read action from $_GET, pass it via the $query argument, not the payload.

3. Register the tool in model.php ​

Open api/backend/mcp/model.php. Add a require_once for the new class next to the others, then chain an ->addTool(...) call before ->build():

php
// in the require_once block (step 5 of apiAction):
require_once $pluginRoot . '/tools/NoindexTools.php';

// in the Mcp\Server::builder() chain, before ->build():
->addTool(
    [\Newmeta\Mcp\Tools\NoindexTools::class, 'setPageNoindex'],
    name: 'set_page_noindex',
    description: 'Set or clear the noindex SEO flag of a page (draft). page_id from list_pages; noindex=true hides the page from search engines once published.'
)

Registration is explicit — discovery is off

The server does not rely on SDK auto-discovery (setDiscovery() finds 0 tools under the plugin-local Composer setup). Every tool needs both a require_once and an ->addTool(...) entry. The name and description on addTool are what the client sees — keep them in sync with the #[McpTool] attribute.

4. Add the scope to the whitelist (if new) ​

If your tool introduces a scope that is not already in the list, add it to private const SCOPES in api/backend/mcptokens/model.php so it can be granted when a token is created:

php
private const SCOPES = ['read', 'design', 'content', 'menu', 'media', 'publish'];

The existing six scopes cover most cases — prefer reusing content for content writes over inventing a new scope.

5. Deploy and reconnect ​

The new code only takes effect on a fresh MCP session:

bash
# 1. Deploy the plugin files (tools/*.php + model.php).
# 2. If composer.json changed, install the plugin-local vendor:
cd _public/extensions/core/backend/mcp && composer install --no-dev --optimize-autoloader
# 3. Reconnect the MCP client (Claude Code / Cursor) so it re-fetches the tool list.

Verify the tool is exposed by calling ping (returns the tenant and granted scopes), then invoke the new tool.

Why structured errors ​

The SDK wraps an uncaught exception in a generic JSON-RPC -32603 error, hiding the real message from the client. Returning ['ok' => false, 'error' => $e->getMessage(), 'error_class' => get_class($e)] surfaces the actual cause (missing scope, validation failure, endpoint error) so the LLM — and the developer reading the transcript — can act on it. Every tool follows this pattern.

Common issues ​

Forgot the ->addTool(...) or the require_once

A tool needs both a require_once $pluginRoot . '/tools/…' line and an ->addTool([Class::class, 'method'], …) entry in model.php. Auto-discovery is intentionally not used — a class on disk without an addTool entry is invisible to clients.

Skipping requireScope() in a write tool

$skipOriginCheck = true and the service session pass the canonical model's backend_loggedin gate, so a write would succeed even from a read-only token. The per-tool requireScope() / requireAnyScope() is the only thing enforcing scope granularity. Gate every write.

Re-implementing model logic instead of looping back

Writing directly into page_row / website / redirects from a tool bypasses validation, draft handling, ID generation, and CSS recompile. Always go through McpHttpBridge::call() to the canonical backend/* endpoint (or backend/item for content_construct=table).

Throwing instead of returning a structured error

An uncaught exception becomes an opaque JSON-RPC -32603 on the client. Wrap the body in try/catch and return ['ok' => false, 'error' => …] so the message survives.

Publishing as a side effect

publish_page requires the literal publish scope (a * wildcard does not cover it) and must be called only on an explicit user request — never as part of a build/edit flow. Keep page writes draft-first.

See also ​