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)DesignWriteToolsbackend/design save_less
Write (content_construct=table)McpHttpBridge::call('backend/item', …)RedirectToolsbackend/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