MCP Server For EE – Documentation
MCP Server For EE is an ExpressionEngine add-on that exposes your CMS to AI assistants through the Model Context Protocol (MCP), with an integrated OAuth 2.1 authorization server, plus an optional anonymous read-only public endpoint. Members authenticate with their ExpressionEngine credentials on the authenticated endpoint; tool access is scoped by OAuth scopes (mcp:read, mcp:write, mcp:admin) and enforced against native EE role permissions. The authenticated endpoint serves both the current MCP protocol revision (2026-07-28) and the older initialize-handshake revisions (2025-03-26 through 2025-11-25) from the same URL, so old and new clients both work without configuration; the public endpoint deliberately serves only the handshake revisions (see below).
Requirements
- ExpressionEngine 7.2+
- PHP 8.2+ with the
opensslandfileinfoextensions (both are standard on typical EE hosts;jsonships with PHP core). The installer refuses to proceed if a required extension is missing. - A non-empty
encryption_keyin your EE config (system/user/config/config.php). EE sets one on install; if yours is blank, the add-on refuses to create or rotate signing keys and tells you so in the control panel — it never falls back to a guessable key. - ExpressionEngine Pro is required only if you want members with multi-factor authentication (MFA) to sign in — MFA support uses EE Pro's own machinery. Everything else works on standard EE.
Composer is not required: the release zip ships with all dependencies bundled.
Installation
- Download the release zip and unzip it, or copy the
mcp_server_for_eefolder intosystem/user/addons/mcp_server_for_ee. The folder already contains itsvendor/dependencies. - In the EE Control Panel, go to Developer → Add-ons and install MCP Server For EE.
- Open MCP Server For EE in the sidebar (superadmin only) and work through its settings pages — start with General to enable MCP, then Access Control to choose who can connect.
- Optionally rotate signing keys from the General page after initial setup.
Upgrades are handled automatically by EE's add-on updater — no reinstall is required. New endpoints and settings are registered on upgrade. When you copy a new version into place by hand, replace the whole mcp_server_for_ee folder rather than copying the new one over the old, so files a release removed do not linger.
The full manual is inside the control panel too: Developer → Add-ons → MCP Server For EE → Manual renders the add-on's own README.md with a section sidebar, and the same file ships at the root of the release zip as DOCUMENTATION.md. This page is its online copy.
Quick start
The fastest path to a working connection with an assistant that has a shell (Cursor, VS Code, Claude Code):
- General page → toggle Enable MCP server on, Save.
- Access Control page → either leave “Allow every member role” on, or pick the roles that may connect.
- Quick Start page → copy the ready-made config block for your client and paste it into that client's MCP config.
- In your client, connect/authorize the server. A browser window opens to your site's sign-in page — log in with an ExpressionEngine member account that has MCP access, review the requested permissions, and click Allow.
- The assistant can now call tools. Try “list the channels on my site” or “how many open entries are in the news channel.”
That's it — no server config changes are required for the ?ACT= URLs the Quick Start page provides. Clean paths like /mcp are optional (see Connecting MCP clients).
Core concepts
The two authentication gates
Every authenticated tool call must pass both of these, in order:
- OAuth scope. The access token carries one or more scopes —
mcp:read,mcp:write,mcp:admin(see OAuth flow & scopes). A tool declares which scope it needs; the token must hold it. - Native EE permission. The authenticated member's own ExpressionEngine role permissions are checked for the specific action — per-channel entry permissions,
can_admin_channels,can_access_members, per-destination file permissions, assigned statuses, and so on.
A scope never widens access beyond what the member's EE role allows. Granting mcp:write to a token does not let a member create entries in a channel their role can't publish to. Scopes narrow; EE permissions are the ceiling.
Per-member tool visibility
tools/list is filtered per authenticated member: an assistant is only shown the tools that member's scopes and EE permissions could actually use. A member with no file-manager access never sees the file tools; a member with no accessible channel never sees the entry tools. This is a usability aid — hiding is cosmetic, and every tool still runs its own permission check on every call.
Auditing
Every mutating tool call (create/update/delete/upload) is written to an audit log visible on the Clients & Audit page. The log records the member, client, IP, action, a hash of the parameters — never the raw parameter values — and the target the call acted on (entry:197, file:1181, category:12; a create is logged under its channel:N or directory:N and pointed at the new id once it exists), so “what touched entry 197” is one filter on that page, or one list_audit_log call from a Super Admin client. External tools have no target. An upload's completion row (upload_file_complete) is audited under the client that minted the upload ticket; its IP is the machine that sent the bytes, which differs from the MCP client's when the assistant runs remotely.
Control Panel settings pages
The add-on's sidebar (MCP Server For EE, superadmin-only) has seven pages, each with its own save action:
| Page | Purpose |
|---|---|
| General | Enable/disable the server, set the OAuth issuer URL, rate limit, CORS origins, debug logging, and rotate signing keys |
| Endpoints & Routing | Copy the ?ACT= endpoint URLs; install/remove clean-URL routing (Apache .htaccess, or a copy-paste Nginx/LiteSpeed snippet) |
| Access Control | Choose which member roles may sign in and use MCP — this controls who can connect, not what they can access once connected (EE channel and CP permissions still apply to every call). Roles are the intended way to control access; below them, Member Exceptions override the role rule for one member at a time, picked from a list of the site's members (ID, username, screen name, email): a deny blocks that member from connecting and from every tool call on an existing connection, whatever their roles allow (Super Admins included); an allow admits a member whose roles are not on the allowed list. A member has at most one exception, and a change takes effect on the member's next request, not only at sign-in. |
| Tools | Enable or disable individual MCP tools globally, for every member — a disabled tool is removed from tools/list entirely. delete_entry and delete_file are disabled by default on fresh installs; existing installs keep whatever choices are saved. Tools are grouped into collapsible sections (Read, Write, Admin, and one per provider add-on), each collapsed by default with an “N of M enabled” count and its own Enable all / Disable all buttons that flip every toggle in that section at once (on the Write section this also switches on delete_entry/delete_file); nothing changes until you press Save. If any other installed add-on has registered tools through the plugin system, an External tools section lists them by provider, each off by default until a Super Admin switches it on — see Extending |
| Public MCP | Opt in to the anonymous, read-only public MCP endpoint: channel/field allowlist, rate limits and a maximum pagination offset (default 500), CORS origins, widget, discovery, logging & privacy settings, blocked IPs, a collapsible Public Tools section (its own Enable all / Disable all), and the Public Usage analytics panel — see Public MCP endpoint below |
| Quick Start | Copy-paste configuration snippets for Cursor, VS Code, Claude Code/Desktop, Codex CLI, ChatGPT (web & desktop), and other MCP clients, plus a one-click Download Plugin Icon (PNG) button |
| Clients & Audit | Review and revoke registered OAuth clients, and browse the mutating-tool-call audit log — each row names what the call acted on (entry:197, file:1181); filter the table to one target by typing it or clicking it, with members and targets shown by name and linked to their CP pages |
OAuth flow & scopes
- The MCP client discovers authorization server metadata from the
oauth_metadataaction URL. - The client registers dynamically (or uses a pre-registered client) via
oauth_register. - The user authorizes via
oauth_authorize(EE login + consent). Members with ExpressionEngine Pro multi-factor authentication enabled are prompted for their authenticator (TOTP) code as part of this sign-in. Backup codes are deliberately not accepted here — EE Pro's single one-time backup code is reserved for recovering control panel access. - The client exchanges the authorization code at
oauth_token(PKCE, S256, required). - The client calls the MCP endpoint with
Authorization: Bearer {jwt}.
Tokens are RS256-signed JWTs; signing keys are encrypted at rest and rotatable from the General page (rotating immediately invalidates outstanding tokens). Refresh tokens rotate on use — the old one is atomically consumed, and reuse of a spent refresh token is rejected. The authorize step only issues scopes the client registered for. Sign-in does not leave you logged into the CP: the OAuth login creates a session only long enough to complete consent.
| Scope | Access |
|---|---|
mcp:read | List/read channels, entries, categories, files |
mcp:write | Create/update/delete content where EE permissions allow |
mcp:admin | Members, roles, site settings, the audit log, and public-usage analytics (still gated by EE permissions) |
A scope never widens access beyond what the member's EE role allows: granting mcp:write does not let a member create entries in a channel their role can't publish to. Scopes narrow; EE permissions are the ceiling.
Connecting MCP clients
ExpressionEngine exposes actions as /?ACT={id} URLs. Use the URLs shown in Endpoints & Routing.
As an optional alternative, the same page can install clean, path-based public routes (/mcp, /mcp-public, /oauth/authorize, /.well-known/oauth-authorization-server, etc.) that some MCP clients discover automatically instead of ?ACT= URLs. On Apache, click Install Public Routes and the page writes an .htaccess block that 307-redirects each path to its ?ACT= action URL (re-install after an upgrade so any rule changes apply); on Nginx/LiteSpeed the page generates the equivalent location = /… { return 307 /index.php?ACT=NN&$args; } lines with your site's real action IDs — paste them, make sure your existing location / block falls unknown paths through to index.php with try_files (many configs end in =404; edit the block you already have rather than pasting a second location /), and reload. If you skip clean URLs entirely, give clients the ?ACT= URLs from the same page — everything works. The redirect is required rather than cosmetic: EE's CSRF protection only exempts ?ACT= requests, and EE rebuilds the query string from the request URI, so an internal rewrite (rather than a redirect) would drop the action and the request would be rejected as an expired form (and 307 specifically, so clients keep the POST method and body).
Cursor (mcp.json)
{
"mcpServers": {
"your-site-mcp-server-for-ee": {
"url": "https://your-site.example.com/?ACT=YOUR_MCP_ACTION_ID",
"transport": "streamable-http",
"oauth": {
"authorizationServerMetadata": "https://your-site.example.com/?ACT=YOUR_METADATA_ACTION_ID",
"protectedResourceMetadata": "https://your-site.example.com/?ACT=YOUR_RESOURCE_METADATA_ACTION_ID",
"scopes": ["mcp:read", "mcp:write"]
}
}
}
}
Replace action IDs with values from MCP Server For EE → Endpoints & Routing (or copy the ready-made block from Quick Start, which fills these in for your site). The server key is derived from your site name. The Quick Start blocks request mcp:read and mcp:write; the admin tools (list_members, get_member, list_roles, get_site_settings, list_audit_log, get_public_usage) need the mcp:admin scope, which a client has to request explicitly — for Cursor, add it to scopes; the consent page then asks the member to approve it. The ?ACT= URLs themselves need no rewrite rules on any host. One caveat for OAuth: the MCP auth spec has clients discover the authorization server at {issuer}/.well-known/oauth-authorization-server (derived from the issuer, query string dropped), so your web server must fall unknown paths through to EE's index.php. Apache's stock EE .htaccess already does this; on Nginx make sure your location / block has try_files $uri $uri/ /index.php?$query_string; (the Endpoints & Routing page gives you the exact snippet) — without it, Nginx answers the discovery path with its own 404 and OAuth clients fail with “Invalid OAuth error response”.
Claude Code & Claude Desktop
Claude Code (terminal): run the claude mcp add command shown on the Quick Start page, then /mcp inside a session to sign in. Claude Desktop / claude.ai: Settings → Connectors → Add custom connector, paste the MCP server URL, and complete the sign-in when prompted.
ChatGPT, VS Code, Codex CLI, and others
ChatGPT (web & desktop) connects as a custom plugin via Settings → Plugins → Developer mode (requires a paid plan with Developer mode enabled, and, on Team/Business/Enterprise/Edu workspaces, an admin allowing custom plugins) — the Quick Start page has step-by-step instructions plus a one-click Download Plugin Icon (PNG) button that builds a ChatGPT plugin icon from the site's own favicon. VS Code (GitHub Copilot, 1.102+) and Codex CLI both get ready-to-paste configuration snippets on the same page. Any other MCP client that supports Streamable HTTP transport with OAuth 2.1 works with the generic snippet also provided there; dynamic client registration is supported, so most clients register themselves automatically.
Chat-only clients and file uploads: an assistant can only upload a file you attach if the client gives it a shell (Cursor, VS Code, Claude Code). Purely conversational clients (Claude Desktop, ChatGPT) cannot upload attachments — this is a limitation of MCP itself, not the add-on. See Uploading files.
MCP tools
These 34 authenticated tools are a separate surface from the 7 anonymous public tools described in Public MCP endpoint below — the public endpoint has its own hardcoded tool set, own registry, and cannot be widened by anything in this section.
Other installed add-ons can register additional tools on this authenticated surface through the add-on's plugin system — see Extending: tools from your own add-on. External tools are opt-in per tool (off until a Super Admin enables them on the Tools page) and run under the same scope enforcement and audit logging as the built-ins.
Every built-in tool carries MCP tool annotations: readOnlyHint: true on every read and admin tool, destructiveHint: true on the delete and update tools, and destructiveHint: false on the create and upload tools — so a client can run a read-only tool without prompting and ask for confirmation before a destructive one. The seven public tools (below) all carry readOnlyHint: true. A provider add-on can pass its own annotations for an external tool (see Extending); these hints are advisory to the client and never change what the host itself enforces.
Any tool can be disabled globally from MCP Server For EE → Tools, regardless of scope or member — a disabled tool is removed from tools/list entirely and cannot be called. delete_entry and delete_file are disabled by default on fresh installs; enable them from the Tools page (grouped into collapsible sections with per-section Enable all / Disable all buttons) if you need agents to delete content.
tools/list is also filtered per authenticated member: a tool the member's OAuth scopes or EE role permissions could never satisfy (no channel access, no can_access_files, etc.) is not offered to that member at all. This is advertisement-only; every tool still enforces its own scope and permission checks on every call, which remains the actual security boundary.
Channels
| Tool | Scope | EE permission | Notes |
|---|---|---|---|
list_channels | read | channel access | Channels the member can access |
get_channel | read | channel access | Field layout, required_fields, statuses, versioning_enabled/max_revisions, and category_groups — each with its categories inlined in CP order, so categoryIds can be picked from this one call; every group also reports category_count and categories_truncated (a group over 200 categories inlines no rows: categories is null and categories_truncated is true; page through it with list_categories). Every field carries the value_format it expects, its filterable_ops (the fieldFilters ops it takes) and its contract keys — file fields allowed_directory and content_type, relationship fields their picker rules, text fields max_length, option fields their options, Grid fields their columns, Fluid fields their children — and a third-party field that reports back on saves is marked after_write: true (see Field contracts). Call before create_entry. |
Entries
| Tool | Scope | EE permission | Notes |
|---|---|---|---|
list_entries | read | channel access | Newest first; optionally filter by category, status and/or fieldFilters (up to 5 {field, op, value?} with empty, not_empty, equals, contains; get_channel reports filterable_ops per field); limit 1–100 |
get_entry | read | channel access | By entryId, or by channelId + urlTitle; full entry incl. custom field values; file fields carry a resolved file object (id, name, directory, mime type, size, URL) beside the raw value; Grid fields carry rows, Fluid fields items; relationship targets are re-gated by channel access |
get_entries | read | channel access | Up to 50 entryIds per call (from list_entries/search_entries), returned in the order given, each in get_entry's shape; optional fields (field names from get_channel) limits which custom fields are included, and fields: [] returns native properties only; ids that do not exist or are not accessible come back in skipped, unknown field names in unknown_fields; more than 50 ids is refused, not clamped |
list_entry_revisions | read | channel access + edit permission on the entry | Saved revisions newest first (version_id, author_id, version_date, count), for channels with versioning on (get_channel reports versioning_enabled/max_revisions); written on every data-changing save, CP or MCP; limit 1–100 |
get_entry_revision | read | channel access + edit permission on the entry | One revision by version_id: native properties and every custom field value as of that save, keyed by field_name; Grid/Fluid/relationship values in the raw captured form, marked complex |
search_entries | read | channel access | Title + text custom fields, ordered by title; query 3–100 characters (the same bounds as the public surface); optionally filter by category, status and/or fieldFilters (as list_entries) |
count_channel_entries | read | channel access | Optional status/category/query/fieldFilters filters (the same fieldFilters as list_entries, so the count is the progress meter for a fix-up pass); query 3–100 characters; only query-filtered counts are capped at 10000 |
count_category_entries | read | channel access | Site-wide or per-channel; restricted to accessible channels; the query filter (3–100 characters) needs channelId; only query-filtered counts are capped at 10000 |
create_entry | write | per-channel create + assigned statuses | See Creating & updating entries; returnFields trims the returned entry ([] = native properties only); dryRun: true runs every check and returns would_create without writing; the URL title is derived from the title (optional urlTitle, see URL titles: a derived one that is already taken is refused so the assistant asks you); field_reports beside entry when a touched third-party fieldtype reports after the save |
update_entry | write | per-channel edit + assigned statuses | A merge per top-level field — omitted fields are left unchanged — except Grid and Fluid arrays, which are always the complete set of rows/items (see Field contracts). Self/other edit permission split honored; returnFields as create_entry; dryRun: true returns would_change ({from, to} per touched title/url_title/status/field/categories) without writing; the URL title is kept unless urlTitle is passed, and a change is reported as url_title_changed; field_reports as create_entry |
update_entries | write | per-channel edit + assigned statuses | Up to 25 update_entry items per call ({entryId, title?, urlTitle?, fields?, status?, categoryIds?}), each with its own {entry_id, ok, entry} or {entry_id, ok: false, error} result in order plus updated/failed counts; a failed item never stops the rest and nothing spans items, so retry by entry_id; duplicate ids, an item with no instruction and more than 25 items are refused before anything is written; returnFields and dryRun as update_entry (every item dry-run, would_update/failed counts); a value identical to the stored one is still written and bumps edit_date; an item that changed the URL title carries url_title_changed |
delete_entry (disabled by default) | write | per-channel delete | Enable on the Tools page |
Categories
| Tool | Scope | EE permission | Notes |
|---|---|---|---|
list_category_groups, get_category_group | read | access to ≥1 of the group's channels | |
list_categories, get_category | read | access to ≥1 of the group's channels | list_categories pages with limit (1–1000, default 200) / offset and returns count (the group total) and has_more — the same rows and order as get_channel inlines |
create_category_group, create_category, update_category | write | can_admin_channels | parentId must be a category of the same group (never the category itself or one of its descendants) |
Files
| Tool | Scope | EE permission | Notes |
|---|---|---|---|
list_file_directories | read | file manager access | Only destinations assigned to the role; returns parsed URLs plus what each accepts (allowed_types, images_only, max_size_kb, max_width, max_height) |
list_files, get_file | read | file manager + assigned destination | list_files pages one destination (limit 1–200, folderId for a subfolder) and takes an optional query (file name or title contains, 2+ characters) that searches the whole destination, subfolders included, ignoring folderId; every file record (list_files, get_file, update_file, uploads) carries modified_date, file_size (exact bytes), upload_date, description, credit, location and usage_count |
upload_file | write | file manager + upload + assigned destination | Mints a one-time upload URL — see Uploading files |
upload_file_inline | write | file manager + upload + assigned destination | Tiny files only (< 64 KB), base64 |
update_file | write | file manager + edit + assigned destination | Rename in place (same extension, same folder) and/or edit title, description, credit, location — see Renaming a file |
delete_file (disabled by default) | write | file manager + delete + assigned destination | Enable on the Tools page |
Members & admin (read-only)
| Tool | Scope | EE permission | Notes |
|---|---|---|---|
list_members, get_member, list_roles | admin | can_access_members | No password/secret fields ever returned |
get_site_settings | admin | system prefs | Name/URL/index/site id only |
get_public_usage | admin | Super Admin only | Public-endpoint analytics (see Public MCP endpoint) |
list_audit_log | admin | Super Admin only | The mutating-call audit log, newest first, with member name and target name resolved; filter by target (entry:197), toolName, memberId; count for the filter; page with beforeId (the last id of the previous page, stable while the log grows) and resume with afterId (everything since a row you hold); limit 1–100; never the call's parameters, only their hash |
Resources
Read-only context resources are also exposed: ee://site/channels (the site's channels), ee://site/channel/{id}/fields (a channel's field layout), and ee://member/permissions (the calling member's own permission context). The public endpoint exposes one resource of its own, ee://public/site; none of these three are served there.
Creating & updating entries
Creating an entry cleanly is a short sequence for the assistant:
- Call
get_channel(channelId)first. It returnsrequired_fields(the field names you must supply),statuses(the channel's statuses),category_groups(each with itscategoriesinlined in CP order, plusrequired/allow_multiple), and for every field avalue_formathint describing exactly what to pass. File fields also report which upload directory they accept files from (see Field contracts). - Upload any files first (see below) so you have real file IDs.
- Call
create_entrywithchannelId,title,status, thefieldsobject, and optionallycategoryIds.
The fields parameter is an object keyed by field name. Values are the stored form: text/HTML as strings, file fields as "{file:ID:url}" (the ID from upload_file/list_files), relationship fields as an array of existing entry IDs, Grid fields as an array of row objects keyed by column name, Fluid fields as an array of items (see Field contracts for all three). title, urlTitle and status have their own parameters; you may also set the native entry_date, expiration_date, and sticky through the map (a url_title key there is still accepted and treated as urlTitle), but author_id, channel_id, site_id, status, and title are deliberately not reachable there. The server also accepts the common list shapes some assistants produce and normalizes them.
{
"channelId": 3,
"title": "Annual report 2025",
"status": "open",
"categoryIds": [4, 29],
"fields": {
"seo_title": "Annual report 2025 — Example Co.",
"report_description": "<p>…</p>",
"report_pdf": "{file:1210:url}",
"report_city": "Springfield"
}
}
Bulk passes: finding entries by field state, batch updates, smaller responses
An audit or fix-up pass (“which reports still have no flipbook?”) starts with fieldFilters, available on list_entries, count_channel_entries and search_entries: a list of up to five {field, op, value?} objects, AND-combined with the category/status/query filters. field is a field_name from get_channel; op is empty, not_empty, equals or contains (the last two take a value). get_channel reports filterable_ops per field: text-like, option, date, file and provider fields take all four (a file field's equals takes a file id); relationship fields take empty/not_empty/equals with a related entry id; Grid and Fluid fields take empty/not_empty. The filters run in SQL, so the listing keeps its order and paging, and the count with the same filters is the progress meter after an interruption.
{"channelId": 3, "fieldFilters": [{"field": "report_yumpu_embed", "op": "empty"}], "status": "open"}
Read the matches with get_entries — up to 50 ids per call, in the order given, each in get_entry's shape — and pass fields to fetch only the custom fields you need (or fields: [] for native properties only), which skips the Grid/Fluid/relationship/file lookups for everything else. On the write side, returnFields on create_entry/update_entry/update_entries works the same way: omit it for the full entry, pass [] for native properties only (the smallest response for a bulk pass), or a list of field names for just those; names the channel does not have are listed in unknown_fields at the top level of the result (beside entry on a single write; once for the whole batch on update_entries, not per item).
For the fix-up itself, update_entries takes up to 25 update_entry items ({entryId, title?, urlTitle?, fields?, status?, categoryIds?}) and returns one result per item in order, {entry_id, ok: true, entry} or {entry_id, ok: false, error} with the message update_entry would have given, plus updated and failed counts. Nothing spans items: a failed item never stops the rest and there is no cross-item transaction, so a partial batch is retried by entry_id from the failed results. Duplicate ids, an item carrying no instruction and more than 25 items are refused before anything is written. “No instruction” means none of title, urlTitle, fields, status, categoryIds, not “same as stored”: a value identical to the current one is written and bumps edit_date, because comparing first would mean reading every entry, which is what the batch exists to avoid. The cap is 25 rather than 50 because a provider fieldtype's save can call out per entry and a batch has to finish inside the client's request timeout.
Dry runs
create_entry, update_entry and update_entries take dryRun: true. It runs everything the real call runs (field name resolution, every field contract, categories, the status gate, EE validation) and stops before the save: a refusal is the same one the real call would give; success returns {dry_run: true, valid: true, …} with would_change on update_entry ({from, to} for each touched title, url_title, status, field and the category ids, from being the stored value as get_entry reports it and to the value as the fieldtype would receive it) or would_create on create_entry (including the url_title that would be derived, or the urlTitle you passed). update_entries dry-runs every item and reports would_update/failed instead of updated/failed; no dry run ever carries field_reports. Nothing is written and no audit row is logged. A dry run is not a reservation: the real call runs the checks again, so a value another editor changes in between is caught then.
Revisions
When a channel has versioning on (Developer → Channels → edit → Settings → Versioning; get_channel reports versioning_enabled and max_revisions), EE saves a revision on every save that changes the data, from the CP or through MCP. list_entry_revisions(entryId) lists them newest first and get_entry_revision(versionId) returns one with every field value as of that save, keyed by field_name. Both need edit permission on the entry, as the CP's Revisions tab does. This is the tool for “this field is empty and I do not know why”: the revision before the change shows what was there, and author_id and version_date say who saved and when. Three things to know before reading a history: history exists only from the moment the channel setting was switched on (list_entry_revisions returns versioning_enabled and count so a client can tell “no history yet” from “no history at all”); version_id is one sequence across every entry on the site, so gaps in an entry's list are normal; and max_revisions is per entry, so once an entry has that many, each further save drops its oldest. Revisions are a shallow safety net, not an audit log — the control panel's audit log (and list_audit_log) is the record of what MCP wrote and when.
Field contracts
A write through MCP is exactly as permissive as the control panel's publish form, never more. Where a field's own settings restrict what its CP picker offers but EE's own fieldtype validation doesn't check it, the entry tools enforce the contract themselves: get_channel reports it so a client can plan, and create_entry/update_entry (and every update_entries item) refuse a mismatch before anything is saved. Every rule is read from the field's own settings, never from a site's field names, and EE's own validation still runs afterwards on whatever passes. Grid columns and Fluid children get exactly the same contracts as top-level fields of their type.
- File fields can be restricted to one upload directory and to images only (EE itself only checks that the file exists and the member may access its directory).
get_channelreportsallowed_directory(the destination's id, name, allowed types, image-only flag, and size/dimension limits — ornullfor any directory) andcontent_type(allorimage). A file from the wrong directory, or a non-image on an image-only field, is refused, naming the field, the file's actual directory, and the required one. Reading back,get_entry(and the entry returned bycreate_entry/update_entry) gives each file field afileobject —file_id,file_name,title,upload_location_id,directory_name,mime_type,file_size,modified_date,url— ornullwhen the field is empty or the file no longer exists, so a write can be verified without aget_fileper field. Pipeline: readallowed_directoryfromget_channel, pass thatupload_location_idas thedirectoryIdofupload_file, then set"{file:ID:url}"on the entry; empty values (clearing the field) pass through untouched. - Relationship fields restrict candidates by channel, status, category, and author in the CP, none of which EE's own save validation checks.
get_channelreportsallowed_channels,allowed_statuses,allowed_categories,allowed_authors(each a list ornullfor any),allow_multiple,min_entries/max_entries(multiple-select only), andallow_expired/allow_future. The entry tools refuse an entry that does not exist on this site, is in a channel the field does not allow, has a status it does not allow, is expired or future-dated when the field excludes those, is by an author or in none of the categories the field allows, or more than one id on a single-select field — each with a message naming the entry, what it is, and what the field allows (e.g.Field report_testimonial only relates to entries in "testimonials" (id 1); entry #63 is in channel "events" (id 7).). The value is an array of entry ids ([12, 34]; a single id or EE's own{"data": [12, 34]}shape is accepted too), and[]clears the field. - Text fields. A field's max length is only the CP input's
maxlengthattribute — EE doesn't check it on save, so an over-long value would otherwise save silently.get_channelreportsmax_length(ornull) andcontent_type(all,numeric,integer,decimal); a longer value is refused with the limit and the actual length. A non-numeric value on a numeric field is still refused by EE's own validation, reported with the field name. - Option fields (
select,radio,checkboxes,multi_select,selectable_buttons).get_channellistsoptions(value/label pairs in CP order — listed items or values pre-populated from another field, exactly as the CP resolves them) andallow_multiple. Pass one value for a single-value field (a one-item list is accepted), an array for a multi-value one (EE's own"a|b"string works too);""or[]clears. An unlisted value, or a list on a single-value field, is refused, naming the allowed values. - Grid fields.
get_channellists each column'scol_id/col_name/col_label/col_type/requiredplus the same contract keys a top-level field of that type would carry, and the grid's ownmin_rows/max_rows.get_entryreturnsrowsin display order, with file columns resolved:[{"row_id": 3, "values": {"caption": "…", "photo": "{file:12:url}"}, "files": {"photo": {…}}}]. To write, pass an array of row objects keyed by column name as the field's value — the array is the complete set of rows: a row with arow_idfromget_entrykeeps that row (with its new values/position), a row without one is new, and rows left out are deleted, exactly as in the CP;[]clears the field. Every cell is checked against its column's own contract before save (namedfield.columnin the message), a missing required column and the row limits are refused up front, and arow_idthat is not the entry's own is refused. File Grid fields work the same way: EE's fixedfilecolumn comes first and carries the field's directory restriction, and it is required in every row when the field itself is required. - Fluid fields.
get_channellists the childfieldsthe Fluid field allows and its fieldgroups(each with its own fields), all carrying their own contract keys.get_entryreturnsitemsin display order:{"id": 15, "field": "body", "field_type": "textarea", "value": …}for a single field (withfile,rowsor related ids as the type needs) and{"group": "hero", "group_id": 9, "fields": {"heading": {"id": 18, …}, …}}for a field-group instance. To write, pass an array of items —{"field": "<name>", "value": …}or{"group": "<short_name>", "fields": {"<name>": …}}— as the field's value — again the complete set of items: an item with anidfromget_entrykeeps that item (for a group, the id of any of its fields; fields you leave out keep their value), one without is new, items left out (and their data) are deleted, and[]clears the field. Every child value goes through its own contract, so a file column inside a Grid inside a Fluid item is checked exactly like a top-level file field (namedfluid.fieldorfluid.group.fieldin the message), and a required child left empty is refused by name. What EE's own child validation still refuses after that comes back as one message for the whole Fluid field, because EE keeps the per-child detail private; the same holds for a Grid cell. - Third-party fieldtypes. An add-on that ships a fieldtype can describe its stored value to the host through the field-type hook (see Extending);
get_channelthen shows that text as the field'svalue_formatand, when the provider named one, abuild_withkey naming the tool that produces it. Descriptive only — the fieldtype's own validation still decides what saves. A provider can also report back after a save:get_channelmarks such a fieldafter_write: true, andcreate_entry/update_entry(and eachupdate_entriesitem) then return afield_reportsobject besideentry, keyed by field name, holding whatever the provider returned for that field (Yumpu Embed reports its remaining APIquota, since a save can itself cost one Yumpu call). Present only when a touched field had something to report; never on a dry run; a provider failure drops the report and never fails the write.
- URL titles (
url_title).create_entry,update_entryand eachupdate_entriesitem take an optionalurlTitle.- Create, omitted (the normal case): generated from the title, lowercased, the same way the control panel does it. An assistant should leave
urlTitleout unless you gave it one. - Create, when the derived slug is taken: the call is refused, naming the entry that already holds the slug, and nothing is created. The assistant is told to ask you what URL title the entry should have and to pass your answer as
urlTitle; it is told not to invent one, and the add-on never appends a suffix of its own, so an entry is never published under a URL nobody chose. The same refusal covers a title that derives to a number or toindex, or that has no characters a URL title can use. The one exception is a channel with Enforce automatic URL title on, where the publish form itself suffixes: there a suffix is added, to a taken slug and to one that derives to a number or toindexalike, and the result says so (url_title_suffixed:{wanted, used, reason, held_by_entry_id, held_by_title}; the holder keys arenullwhen the reason is not a holder). - Update, omitted: the URL title is left alone. A new
titlenever re-derives it, so a bulk pass over titles or fields cannot disturb a slug. A blankurlTitle(or a blankurl_titleinsidefields) counts as omitted, on update and on create alike, so a client that copiesget_entry's shape with the slug left empty is not refused. - Passed (a slug you chose): lowercased, and otherwise used exactly as given, or refused. It is never cleaned, truncated or suffixed. Lowercasing is the one normalisation, because EE lowercases every slug it derives and mixed-case URLs behave differently from one server to the next; an existing mixed-case slug set in the control panel is left alone, and sending it back unchanged is not a change (nor is a value that only differs from the stored slug in case, since it lowercases to it). The rules are the publish form's, plus two from EE's own entry API (not purely numeric, which a URL cannot tell from an entry id, and not
index; a title that derives to either is refused on create, asking forurlTitle): letters, numbers, dashes, underscores, periods and emoji only; at most 200 characters; unique within the channel, compared case-insensitively (the refusal names the entry that already holds it). An invalid value is refused with the closest valid form so you can decide. WhenurlTitleand aurl_titleinsidefieldsare both given they must lowercase to the same slug, or the call is refused. - Channels with Enforce automatic URL title on: the publish form's field is read-only, so
urlTitleis refused there too.get_channelreports the whole contract as aurl_titleobject:auto_generated,max_length,allowed_characters,lowercased,refused_values,unique_within_channeland anote.
url_title{from, to}inwould_change, and the real result carriesurl_title_changed{from, to}(per item inupdate_entries). Sending back the value that is already stored is not a change. Inupdate_entries, an item whose only content is aurl_titlekey with no value (nullor blank) insidefieldscarries no instruction and is refused up front, like any other empty item. A slug outside these rules (mixed case, for instance) is a control-panel edit: the tools only ever write the safe form. - Create, omitted (the normal case): generated from the title, lowercased, the same way the control panel does it. An assistant should leave
- Categories:
get_channelinlines every attached group's categories (category_id,name,url_title,parent_id, in CP order) with the group'srequiredandallow_multipleflags, socategoryIdscan be picked from that one call.categoryIdsassigns through EE's own category setter, so each channel's per-group required and single-selection rules apply. A category from a group not attached to the channel, or from another site, is rejected with a clear message. Onupdate_entry, omitting the parameter leaves categories unchanged; passing[]clears them. - Statuses are enforced against the member's assigned statuses; a refused status tells you which ones the role may use.
- Validation errors come back as readable, field-named messages (e.g. a file field pointing at a nonexistent file), so the assistant can correct and retry.
Uploading files
MCP tool arguments pass through the model's own output, so a file's bytes normally have to be base64-encoded into the call — fine for a small icon, impractical (or outright rejected by the client) for a 50–100 MB PDF or video. upload_file avoids that entirely: it returns a single-use, 15-minute upload URL plus two ready-to-run shell commands — command (curl -sS -X POST --data-binary @<file> …, any OS) and command_powershell (Invoke-RestMethod -Method Post -InFile …). The model runs one of them from its own shell and the bytes travel straight from disk to the upload endpoint — they never pass through the model or the MCP transport. The command uses POST to the explicit /index.php?ACT=… URL rather than PUT to a clean path, because Nginx's static handler answers PUT / with 405 before PHP is reached; run the command exactly as returned. The ticket is single-use (a failed attempt means minting a new one), is claimed atomically before the body is read, and re-checks the member's upload permission for that destination at delivery time in case it changed since the ticket was minted. Tested to ~100 MB (governed by the destination's own limit and your PHP upload_max_filesize/post_max_size; an absolute 512 MB ceiling always applies).
upload_file_inline is the small-file fallback: base64 content, hard-capped at 64 KB decoded regardless of the destination's own configured limit — large enough for an icon or a small cover image. Anything bigger is rejected with a message pointing back at upload_file.
Duplicate filenames (onConflict)
Both tools take an optional onConflict parameter controlling what happens when the requested fileName already exists at the destination. Both also report whether the name is already taken: upload_file's ticket response carries file_exists, existing_file, and on_conflict; upload_file_inline reports the same outcome fields directly on the finished upload (renamed_from or replaced).
onConflict | Behavior |
|---|---|
rename (default) | Uploads under a suffixed name (e.g. cover_1.png) if the requested name is taken — the existing file is untouched. The response includes renamed_from when this happened. |
replace | Overwrites the existing file in place, keeping the same file_id — use this when updating a file that entries already reference, so the reference doesn't break. Requires can_edit_files (or superadmin) on top of the normal upload permission; on a name that isn't taken it behaves exactly like rename. Fail-safe: the original is only discarded after the replacement validates. |
fail | Rejects the call outright if the name is taken, naming the conflicting file_id and suggesting replace/rename instead — nothing is uploaded. |
A replace updates the record's file_size (exact bytes), mime_type and modified_date. upload_date is when the record was first created and does not move, exactly as when a file is overwritten in the File Manager; to ask “was this file replaced or edited since?”, compare modified_date, which every file record carries.
Three things worth knowing before relying on this:
- Chat-only clients (Claude Desktop, ChatGPT) cannot upload files this way. A chat-only client has no shell to run the upload command with — there is no “attach a file” channel in MCP itself. The ticket flow only works for clients that can execute a local command (Claude Code, Cursor, and similar coding agents).
- PHP's own upload limits still apply. When the destination has no configured
max_size, the ticket'smax_bytesfalls back tomin(upload_max_filesize, post_max_size)fromphp.ini(with an absolute 512 MB ceiling always applied). On Nginx also raiseclient_max_body_size(default 1 MB, returns413before PHP runs) — the generated snippet on Endpoints & Routing includes the line. - Executable types are always rejected, including disguised extensions. SVGs from non-superadmins are rejected in image destinations. ExpressionEngine's own content XSS clean rewrites any SVG (it strips the
xmlns), so a non-superadmin cannot upload one — exactly as in EE's control panel file manager. Superadmins can. Use PNG/JPG/WebP for uploads by non-superadmin roles.
Renaming a file (update_file)
update_file(fileId, fileName?, title?, description?, credit?, location?) edits a file record in place; only the parameters you pass change. It requires the Edit files permission and an assigned destination, like the control panel's own rename. A rename keeps the file_id, so entries that reference the file are unaffected, and the old name's thumbnails are dropped so EE regenerates them.
fileName is a bare name (no path) and must keep the current extension: a rename is never a way to change a file's type, destination or folder — upload again for that. The name is cleaned the way the file manager cleans uploads (spaces become underscores; the response reports requested_file_name when that changed it) and is refused when it is already taken in the same destination and folder, on disk or in the table. It is also refused when it carries an executable segment before the final extension (probe.php.png) on any destination, or, on a destination that restricts file types, any inner segment the destination does not accept (probe.cgi.png on an image destination; digit runs such as v2.1 are fine): EE's upload path would have stored such a name differently, and a rename may not produce a name an upload could not. If you rename without passing title and the title was still the file name, the title follows the new name; an empty title resets it to the file name. The response is the file row plus renamed_from when the name changed, and a usage block (the entry and category ids that use the file, from EE's own file-usage table) so you know what the file touches; entry_ids names only entries the member can open, entry_count stays exact, and hidden_entry_count carries the difference when there is one.
EE 7 stores file references as {file:ID:url}, so a rename does not break entries. The one form a rename would break is the legacy {filedir_N}name reference, which a site running file_manager_compatibility_mode, or content never re-saved since EE 6, may still hold. Before renaming, update_file scans every channel field, Grid column and category image for that exact reference and refuses when any is found, naming the entries and fields; re-save those entries (EE 7 rewrites the reference to {file:ID:url} on save) or run Utilities → File Usage, then rename. Metadata-only edits are never blocked by this. Every file record (list_files, get_file, update_file, uploads) also carries modified_date, file_size (exact bytes), upload_date, description, credit, location and usage_count, and list_files takes a query (file name or title contains, 2+ characters) that searches the whole destination, subfolders included, so an external asset can be matched to a file without paging a directory.
Public MCP endpoint (optional, off by default)
The add-on can also expose a separate, anonymous, read-only MCP endpoint so any AI agent can add your site without an account — a “connect this site to my AI assistant” button for visitors, not a developer integration. It is off by default and, once on, exposes only content an anonymous visitor could already see on the front end.
Safety model
- This is a separate surface from the OAuth server — its own action, own tool classes, own hardcoded tool list. It shares no code path with the authenticated tools and can never widen what the OAuth server exposes; the two enable toggles are independent of each other.
- Every query is filtered to:
status = 'open'only (the literal string; custom statuses never qualify),entry_datealready passed,expiration_dateunset or in the future. - Channel allowlist, default none. An administrator must opt each channel in individually on the Public MCP page. Per-channel field allowlist. For each allowlisted channel, the administrator picks which custom fields are exposed. Both allowlists are intersected with what actually exists today, so a stale or foreign id can never leak a deleted or cross-site channel/field.
- No author or member data is ever returned. File fields are off unless you allowlist them, and then return only the file's public URL.
- Relationship fields return only related entries that independently pass the same visibility check — a relationship can never become a side door into hidden content. Fields render through the fieldtype's own API, never EE's template parser — no
{exp:…}tag ever executes for an anonymous caller. - A hidden entry is always reported as “not found,” never “forbidden” — its existence is never revealed.
The 7 public tools
| Tool | What it returns |
|---|---|
list_public_channels | Allowlisted channels with the custom fields exposed on each (name, label, type) |
list_public_entries | Published entries in an allowlisted channel, optionally filtered by category (capped at 25 per page, limit/offset) |
get_public_entry | A single published entry by entryId, or by channelId + urlTitle |
search_public_entries | Title and allowlisted text-field matches within an allowlisted channel (query must be 3–100 characters — a shorter or longer query is refused with a message saying so; capped at 25 per page) |
list_public_categories | Category groups/categories reachable from allowlisted channels (names and descriptions only) |
get_public_category | A single category with its published entry count |
count_public_entries | A capped count of published entries in a channel, optionally scoped to a category |
All seven carry the readOnlyHint: true MCP annotation. A matching ee://public/site resource returns the site name/description, base URL, allowlisted channel summary, the public tool names, and discovery info — it is a resource distinct from the three the authenticated endpoint serves.
Per-tool switches: the seven tools live in a collapsible Public Tools section on the Public MCP page, with the same “N of M enabled” count and Enable all / Disable all buttons as the authenticated Tools page — the buttons only flip the toggles on the page, and nothing changes until you save. A switched-off tool is removed from the public endpoint's tools/list and from the discovery server card, and a direct call to it is refused — before the rate limiter, so refused calls never consume your rate budget — and recorded in the usage log with a disabled outcome. These switches are entirely separate from the authenticated Tools page; neither surface's toggles affect the other.
Protocol revisions: the public endpoint deliberately serves only the initialize-handshake protocol revisions, 2025-03-26 through 2025-11-25. A request claiming the current 2026-07-28 revision is refused with the list of supported revisions, because that revision's protocol-level methods would bypass the per-tool gate and rate limits on an anonymous surface. The authenticated endpoint (above) serves both eras from the same URL.
Enabling it: go to MCP Server For EE → Public MCP, turn on Enable the public MCP endpoint (while it is off the endpoint answers 503 immediately), tick the channels (and, per channel, the fields) you want exposed, review the tool switches and rate limits, and save. Expanding a channel shows Enable all / Disable all buttons above its field list that flip every field of that channel at once (Enable all also switches the channel on); the toggle at the right of the channel's header is the channel's own on/off, and nothing changes until you save.
Abuse controls & logging
- Rate limits: a per-IP tool-calls-per-minute limit (default 30) and a site-wide ceiling across all IPs (default 300), both admin-configurable, enforced on every tool call — plus a coarser request-level guard against raw protocol flooding. A maximum pagination offset (default 500) caps how far
offsetmay page, so a caller cannot walk a whole channel a page at a time. - IP/CIDR blocklist, checked before any other work; block or unblock an IP directly from the Public Usage panel.
- Usage log: every public tool call is recorded (tool, channel/entry/category, search query, IP, user agent, MCP client name/version, outcome). Retention defaults to 30 days; IPs are anonymized after 7 days by default; logging of search query text can be turned off independently. The Public Usage panel shows volume, top channels/searches/entries, top clients and IPs, and lets you export the filtered log as CSV.
- Every response carries
X-Robots-Tag: noindex, and CORS only echoes an origin that is explicitly allowlisted in the Public MCP page's CORS origins (empty allowlist = no CORS headers at all; only browser-based clients need an entry). - Behind a CDN or load balancer? The per-IP limits, blocklist, and log all use the caller IP as ExpressionEngine reports it. EE only trusts
X-Forwarded-For/CF-Connecting-IPwhen the connecting proxy is listed in your site'sproxy_ipsconfig — make sure that list contains only your real proxy addresses. If it is over-broad, a caller can forge a new forwarded IP per request and sidestep the per-IP controls (the site-wide ceiling still applies).
Widget
An optional, dismissible “Add this site to your AI agent” button that shows visitors your public endpoint URL. Two ways to place it, either or both:
- Automatic: turn on the widget toggle on the Public MCP page and it is injected before
</body>on every front-end HTML page. - Template tag, for explicit placement:
{exp:mcp_server_for_ee:public_widget}
{exp:mcp_server_for_ee:public_url}
{exp:mcp_server_for_ee:public_url clean="yes"}
public_widget renders the full widget markup; public_url renders just the endpoint URL (the ?ACT= URL by default, or the /mcp-public clean URL with clean="yes"). On Apache, clean="yes" falls back to the ?ACT= URL until the public routes are installed from the control panel, so a template never advertises a 404. Using the tag on a page suppresses the automatic injection there, so you never get two copies. Position (4 corners), label text, and primary/accent colors are configurable on the Public MCP page.
Discovery
When discovery is on, the add-on publishes a server-card JSON document at /.well-known/mcp/server-card.json (and, for compatibility, the same document at /.well-known/mcp.json and /.well-known/mcp-server), and injects <link rel="mcp-server"> into the front-end <head>. MCP server discovery is still a competing-drafts situation; this add-on emits both drafts' field names where they disagree. Treat discovery as best-effort, not a stable contract.
Connecting anonymously
No OAuth block, no credentials — just the endpoint URL. Find the ?ACT= URL on the Public MCP page (or use the /mcp-public clean URL if you've installed public routes from Endpoints & Routing):
{
"mcpServers": {
"expressionengine-public": {
"url": "https://your-site.example.com/?ACT=YOUR_MCP_PUBLIC_ACTION_ID",
"transport": "streamable-http"
}
}
}
Extending: tools from your own add-on
MCP Server For EE (1.0.0 or later) is a platform other ExpressionEngine add-ons can plug into. By subscribing to one extension hook, your add-on's tools appear on the site's authenticated MCP endpoint alongside the built-in content tools — OAuth-authenticated, scope-enforced, audit-logged, and individually enableable by the site's Super Admin, with none of that machinery written by you. This section is an overview; the full developer guide — the complete addTool() reference, schema rules, installer wiring, a testing recipe, and an integration checklist, written so an AI coding agent can implement the integration from it directly — ships as docs/extending-mcp-tools.md inside the add-on's zip. Beside it, skills/mcp-server-for-ee-integration/ is an agent skill for the same job: a SKILL.md with the working order, templates for the extension file, installer wiring and tool class, an AGENTS.md snippet, the guide bundled as its reference, and scripts/verify_integration.php, which boots a local EE install and reports what your add-on registered, the JSON Schema each tool will advertise, whether each is enabled yet, and whether your hook stays silent against an unknown API version. Copy the folder into your add-on repository's .claude/skills/ (or wherever your agent loads skills from) and an AI coding agent can carry out the integration from it.
The first add-on built on this API is Multimediality's own Yumpu Embed (currently 1.4.9), which registers nineteen tools through it and reports its API quota back after every entry save that touches one of its fields; the three steps below are the same ones it follows.
How it works
When the MCP endpoint assembles its server, it fires the extension hook mcp_server_for_ee_register_tools with one argument: a Multimediality\McpServerForEe\Api\ToolRegistrar. Your extension method calls $registrar->addTool([...]) once per tool. From then on the host add-on owns the runtime path: it reflects your handler method to generate the tool's JSON Schema, advertises the tool in tools/list (only to callers whose token allows it, and only once a Super Admin has enabled it), and wraps every call in its own enforcement proxy before your code runs.
The hook fires more often than a live client call: on every MCP request to the OAuth endpoint — including unauthenticated ones that will simply 401 once the transport middleware validates the token, where there is no member context at all — and on every load of the host's own Tools settings page in the control panel, which collects your definitions to build its External tools list. Registration must therefore be cheap: no HTTP calls, no writes, no loops over content. One small indexed read to personalize a tool's description (naming a site-specific id) is fine.
Step 1 — subscribe to the hook(s)
Create an ext. file (or extend your existing one) with a method for each hook you use, and ensure the extension rows from your installer on both install and update — a site upgrading your add-on from a release that didn't know a hook must still gain that row, not just a fresh install. A plain class is fine; no base class required.
<?php
// ext.your_addon.php
class Your_addon_ext
{
// Both rows are ensured on install AND update: EE only fires a hook
// that has an enabled extension row for it.
private const HOOKS = [
'mcp_server_for_ee_register_tools' => 'register_mcp_tools',
'mcp_server_for_ee_register_field_types' => 'register_mcp_field_types',
];
public $version = '1.0.0';
public $settings = [];
public function activate_extension()
{
$this->ensureHookRows();
}
public function update_extension($current = '')
{
$this->ensureHookRows();
ee()->db->where('class', 'Your_addon_ext')->update('extensions', [
'version' => $this->version,
]);
}
public function disable_extension()
{
ee()->db->where('class', 'Your_addon_ext')->delete('extensions');
}
private function ensureHookRows(): void
{
foreach (self::HOOKS as $hook => $method) {
$exists = ee()->db->where('class', 'Your_addon_ext')->where('hook', $hook)
->count_all_results('extensions');
if ($exists) {
continue;
}
ee()->db->insert('extensions', [
'class' => 'Your_addon_ext', 'method' => $method, 'hook' => $hook,
'settings' => '', 'priority' => 10, 'version' => $this->version, 'enabled' => 'y',
]);
}
}
public function register_mcp_tools($registrar)
{
// Frozen-contract guard: an unknown future API version must be a
// silent no-op, never a guess at its shape.
if (! is_object($registrar) || ! method_exists($registrar, 'apiVersion') || $registrar->apiVersion() !== 1) {
return;
}
try {
\YourVendor\YourAddon\McpTools::register($registrar);
} catch (\Throwable $e) {
// Never break the host add-on's hook run.
}
}
// Only needed if your add-on ships a fieldtype (see below) — a harmless
// no-op guard otherwise.
public function register_mcp_field_types($registrar)
{
if (! is_object($registrar) || ! method_exists($registrar, 'apiVersion') || $registrar->apiVersion() !== 1
|| ! method_exists($registrar, 'addFieldType')) {
return;
}
try {
\YourVendor\YourAddon\McpTools::registerFieldTypes($registrar);
} catch (\Throwable $e) {
// Never break the host add-on's hook run.
}
}
}
Wire the same activate_extension()/update_extension() methods from your upd. installer's install()/update(), and delete the extension rows in uninstall(). When MCP Server For EE isn't installed, the hooks simply never fire — your integration degrades to a no-op with no further guards needed.
Step 2 — the tool class
One class (or several) holding your handler methods. The contract: the class must be constructible with no arguments (the host instantiates it itself — build your own dependencies in the constructor); each handler is a public, non-static instance method; handlers return arrays (the host serializes them). Handlers only ever run inside an MCP Server For EE request, so the host's classes — including Mcp\Exception\ToolCallException — are always loaded; reference them freely inside methods, never at class-load time.
Where the class must live: the host resolves your handler class purely by name (class_exists()) at registration time, and rejects the definition (logged only to the debug log) if it can't find it. EE maps the namespace you declare in addon.setup.php to your add-on's root folder, so Vendor\YourAddon\McpTools must resolve to your_addon/McpTools.php. If your real implementation lives elsewhere (a libraries/ subfolder, say), ship a one-line require_once shim at the mapped path, or register your own autoloader before the hook fires.
<?php
namespace YourVendor\YourAddon;
use Mcp\Exception\ToolCallException;
class McpTools
{
public static function register($registrar): void
{
$registrar->addTool([
'addon' => 'your_addon',
'name' => 'your_addon_list_widgets',
'handler' => [self::class, 'listWidgets'],
'scope' => 'read',
'description' => 'List widgets, newest first. Use widget ids from this list with other your_addon tools.',
'annotations' => ['readOnlyHint' => true],
]);
$registrar->addTool([
'addon' => 'your_addon',
'name' => 'your_addon_rename_widget',
'handler' => [self::class, 'renameWidget'],
'scope' => 'write',
'description' => 'Rename a widget by its numeric id.',
'annotations' => ['destructiveHint' => false, 'idempotentHint' => true],
]);
}
public function listWidgets(int $offset = 0, int $limit = 25): array
{
// ... query your own tables/services ...
return ['widgets' => $rows, 'has_more' => $hasMore];
}
public function renameWidget(int $widgetId, string $name): array
{
$name = trim($name);
if ($name === '') {
throw new ToolCallException('name is required.');
}
// ... your own permission checks + the actual update ...
return ['widget' => ['id' => $widgetId, 'name' => $name]];
}
}
Step 3 — the addTool() definition
addTool(array $definition): bool returns true when accepted. An invalid definition is rejected and logged (to the host's debug log, when enabled), never thrown — one bad definition can't stop your other registrations or another provider's.
| Key | Required | Rules |
|---|---|---|
addon | yes | Your add-on's installed shortname (lowercase, ^[a-z][a-z0-9_]*$). The host verifies it is actually installed. |
name | yes | Lowercase, 3–64 chars, and must be prefixed with {addon}_ (e.g. your_addon_list_widgets). Collisions with built-in tools or another provider's names are rejected. |
handler | yes | [ClassName::class, 'methodName'] — public, non-static, class no-arg-constructible. |
scope | yes | read, write, or admin. The OAuth token must hold the matching mcp:* scope for the tool to be advertised or callable — matching is literal, not hierarchical (a mcp:write-only token does not also satisfy a read-scoped tool). Use write for anything mutating, admin for anything irreversible or sensitive beyond ordinary content editing. |
description | yes | What the AI client reads. Write it for the model: what the tool does, what its ids mean, how it composes with other tools. |
input_schema | no | Explicit JSON Schema (as a PHP array) for the tool's arguments. Omit to have it derived from the handler signature (see below). When present it's validated at registration: root must be type: object, properties an object, every required entry must name a property, and every property must be a parameter of the handler method. |
annotations | no | MCP tool hints, as booleans: any subset of readOnlyHint, destructiveHint, idempotentHint, openWorldHint. Advisory to the client only — never changes what the host enforces. A malformed value rejects the whole definition, same as any other invalid key. |
title, output_schema, icons, and meta are not supported — the host only forwards the four annotation booleans, and never exposes a title, output schema, icons, or metadata for an external tool.
Input schemas
Without input_schema, the host reflects the handler's signature: a typed parameter (int, string, bool, float, array) becomes the matching schema type, and a parameter with a default value is optional. A @param docblock line becomes the property's description, and a more specific docblock shape (array<string,string>, array{a: int, b: string}) beats the plain reflection type for an object- or list-shaped array. A #[Mcp\Capability\Attribute\Schema] attribute on the method or a parameter overrides whatever reflection/docblock inferred, for keywords PHP's type system can't express (enum, pattern, minimum, …). mixed/untyped parameters get no type key at all; a nullable type becomes a union with null; a backed enum becomes an enum list; a variadic parameter becomes an array schema. Never name a parameter $_session or $_request — the SDK reserves those and refuses the tool's schema (the tool then silently never appears in tools/list). Reach for an explicit input_schema only when the schema has to be built at runtime from site-specific data; property names must exactly match the PHP parameter names, and it is the schema — not the PHP type — that validates the call.
Describing your fieldtype's value format
If your add-on ships a fieldtype, the host's get_channel tool otherwise reports fields of that type with the generic value_format: "string", which tells an AI client nothing about what to pass to create_entry/update_entry. A second, optional hook, mcp_server_for_ee_register_field_types, lets you describe the stored value yourself and name the tool that builds it — hand it a Multimediality\McpServerForEe\Api\FieldTypeRegistrar (a separate class from the tool registrar, with its own apiVersion()):
public static function registerFieldTypes($registrar): void
{
$registrar->addFieldType([
'addon' => 'your_addon',
'field_type' => 'your_fieldtype', // the ft.your_fieldtype.php shortname
'value_format' => 'JSON string produced by your_addon_build_value; pass it verbatim, never hand-write it.',
'build_with' => 'your_addon_build_value', // optional: one of your own tools
'after_write' => [\YourVendor\YourAddon\McpTools::class, 'afterWrite'], // optional: report back after a save
]);
}
addon and field_type are required — field_type must be a fieldtype your add-on actually ships, definitions are keyed by field_type alone (the first registrant wins), and the host's own contracts (file, relationship, grid, file_grid, fluid_field) are reserved. value_format is required, at most 1000 bytes, and is what get_channel shows verbatim. build_with is optional and only shape/prefix-checked, not verified to exist. The description is descriptive only — enforcement of your fieldtype's contract stays in its own validate() — and applies wherever the fieldtype appears (including as a Grid column or Fluid child). The hook fires only on the OAuth endpoint: from get_channel, and from the entry write tools after a save that touched a custom field. Existing installs need the row too, which is why ensureHookRows() in Step 1 runs from both activate_extension() and update_extension().
Reporting back after a save (after_write). A fieldtype that does work at save time (a remote call, a derived value) has something to tell the client that only it knows. The optional after_write key names a public static method, afterWrite(array $context): array, validated like a tool handler (class and method must exist; an instance method is rejected). The host calls it after save() returned, on create_entry, update_entry and each update_entries item — never on a dryRun, never after a failed or refused write — once per touched top-level field of your type (a Grid or Fluid field once per provider type, with the top-level field's name, not per cell), with {tool, entry_id, channel_id, field_name, field_type}. Your returned array is attached to the write result as field_reports, keyed by field name, beside entry; get_channel marks fields of your type after_write: true so a client knows a report will come. Containment is the same as for a tool handler: a throwing provider, a non-array return, a return json_encode() cannot encode, or one over 4 KB encoded is dropped and logged, and never changes the write's outcome. Keep it cheap — local state only, no remote calls. Yumpu Embed uses it to return its remaining API quota on the entry write, since a save can itself cost one Yumpu call. The full contract is in the developer guide in the zip.
What the client receives
- A
ToolCallException— yours, one the enforcement proxy substitutes for an uncaught exception, or onerequireScope()throws on a missing scope — becomes a tool result withisError: true, whose text is the exception's message. It is not a JSON-RPC error: the call “succeeded” at the protocol level and failed at the tool level. - Anything else you throw is logged in full to the debug log and replaced with a sanitized, generic message — your real message and stack trace never reach the client.
- Argument validation runs against the advertised schema before your method runs; a failure there is a genuine JSON-RPC error (
-32602), and your handler is never called. - Return an associative array, not a bare list — the host sends it both as a JSON text block and as
structuredContent, and a PHP list (array_is_list()true) is silently dropped fromstructuredContenton protocol revisions that predate structured content for lists. The host never advertises anoutputSchemafor an external tool.
What the host enforces (and you cannot bypass)
- The Super Admin enable-allowlist is re-checked on every call, not just at advertising time.
- The declared scope is required via the host's permission gate, exactly like a built-in tool, and matched literally.
write/admincalls are audit-logged before your handler runs — a refused or failed call is logged too, not just a successful one (member, client, tool name, IP, timestamp, and a hash of the arguments — never the raw values). Visible in Clients & Audit.- Your exceptions are contained (see What the client receives above); to return a deliberate, readable error, throw
Mcp\Exception\ToolCallException.
None of this is per-tool configurable — you cannot register a tool that skips scope enforcement or audit logging. Beyond per-tool enforcement, the whole OAuth endpoint carries limits your tool inherits and cannot opt out of: a per-IP rate limit on the whole endpoint (429 once exceeded); one request at a time per MCP session, enforced with a named lock — a second concurrent request on the same session waits (up to 45 seconds) rather than running in parallel, and gets a “session busy” 503 asking the client to retry if the first request hasn't finished by then, so never advertise one of your tools as safe to call in parallel on a single session; and about 180 seconds per request — give any third-party API call your tool makes its own, shorter timeout.
A scope is a gate, not a grant: declaring admin only restricts who may call the tool. If your tool touches EE content or member-sensitive data, do your own fine-grained permission checks — the calling member is bootstrapped into EE's own session and permission services for the request (in memory only, no session row or cookie), so ee()->session->userdata('member_id') and ee('Permission')->isSuperAdmin() answer for the token's member, and Multimediality\McpServerForEe\Services\MemberContext::instance() exposes the member and granted scopes directly. The host applies no EE-permission visibility filter to external tools — once a Super Admin enables your tool, any member whose token holds its scope can see and call it, regardless of their own EE role.
Opt-in lifecycle & boundaries
- Registering a tool does not activate it. Every external tool starts disabled; a Super Admin enables it individually under Tools → External tools. A new tool added in your next release starts off too — say so in your release notes.
- Ship tool names as a stable contract: renaming a tool means the admin must re-enable it under the new name. Enablement survives your add-on being temporarily uninstalled.
- External tools appear only on the OAuth-authenticated endpoint. The anonymous Public MCP endpoint never accepts third-party tools, regardless of scope.
- A provider whose hook method or tool-class constructor throws is skipped and logged; everything registered cleanly before the failure is kept, and other providers are unaffected. That's containment, not error handling — don't rely on it.
ToolRegistrarandFieldTypeRegistrarcarry independent, frozenapiVersion()constants (currently1). Always check the one you were handed and no-op on anything unfamiliar (as in Step 1); growth happens by adding a new registrar and hook, never by changing an existing method's signature. Tools and field-type descriptions only in version 1 — no resources or prompts yet.
When your tools don't appear
- A specific definition was rejected.
addTool()/addFieldType()log the reason, but only when the host's debug logging setting is on. Turn it on and re-trigger registration (reload the CP Tools page, or make an MCP request) to see why. - The hook fired but registered nothing at all. The host writes this to EE's own Developer Log, naming your extension class, with no debug-logging toggle needed.
- With debug logging on, every collection is traced — including the serving PHP process id, useful for telling a stale opcode cache in one worker (pid differs across refreshes) apart from a genuine registration failure (same pid, still wrong).
- The tools register but never show up in
tools/list. At that point it's not a registration problem: either the tool isn't switched on yet under Tools → External tools, or the calling token's scope doesn't match what you declared.
Testing your integration
You can exercise the registration path from the CLI against a local EE install, without a running MCP client:
$registrar = new \Multimediality\McpServerForEe\Api\ToolRegistrar(
(new \Multimediality\McpServerForEe\Mcp\ToolRegistry())->builtinToolNames()
);
require_once '/path/to/your_addon/ext.your_addon.php';
(new Your_addon_ext())->register_mcp_tools($registrar);
var_dump(array_keys($registrar->definitions())); // your tool names
Then call your tool methods directly to test their logic — the enforcement proxy is the host's concern, not yours. Test the field-type hook the same way, against a new \Multimediality\McpServerForEe\Api\FieldTypeRegistrar(), and assert your version guard actually no-ops against a stand-in registrar reporting an unfamiliar apiVersion(). The developer guide in the zip includes a complete worked example (EE bootstrap from the CLI, the API-version-guard test, and smoke tests with graceful skips). To eyeball the schemas your handlers produce, connect any MCP client to the site with your tools enabled and inspect tools/list.
The release zip also ships an agent skill for the whole job, skills/mcp-server-for-ee-integration/, whose scripts/verify_integration.php does this and more from one command — php verify_integration.php /path/to/ee-root your_addon — against a local install where both add-ons are installed: it reports the extension rows, every tool your hook registered with the JSON Schema the host will advertise, whether each is enabled in the control panel yet, the fieldtype descriptions, that each handler class constructs, that your hook registers nothing against an unknown API version, and (with debug logging on) the reason for every rejected definition.
Multi-Site Manager
On Multi-Site Manager (MSM) installs, the add-on is site-scoped throughout: channels, category groups, upload destinations, and statuses are all resolved for the current site, and the OAuth JWT issuer and audience are derived per site. A category or upload destination that belongs to another site is treated as not found, exactly as if it didn't exist. If you run separate MCP connections for separate sites, each connects through that site's own issuer and metadata URLs.
Troubleshooting & support
If something isn't working, turn on debug logging, reproduce the problem, then download the log and attach it to your support request:
- Go to MCP Server For EE → General, turn on Enable debug logging, and save.
- Reproduce the problem (retry the failed connection, tool call, or sign-in).
- Click Download Debug Log next to the log file path, and attach the downloaded file to your support request.
- Turn debug logging back off once you're done — it's meant for active troubleshooting, not left on indefinitely.
What's in the log: an environment banner (add-on/EE/PHP versions, server software, relevant php.ini limits); one summary line per request to the OAuth MCP, public MCP, and upload endpoints; tool executions and their arguments; tool failures with the real exception class, message, and a short stack trace; OAuth/login failures, MCP transport errors, and public-route install events.
What's never in the log: tokens, secrets, passwords, API keys, cookies, signatures, JWTs, upload tickets, session IDs, or authorization codes — any context value whose key looks secret-bearing is replaced with [redacted] before it's written. Tool results are logged as shape/size only, never content, and file/entry content is never logged. The log is capped at 2 MB and rotates once, and Clear Debug Log removes both the current file and the archive on demand. If the problem is on the client side (“the client stalled”), include the client's own MCP output panel too — the server log only sees what the client sent.
Common issues
| Symptom | Cause / fix |
|---|---|
| “This form has expired” on a clean path | The clean route wasn't a 307 redirect to ?ACT=, or a POST hit a bare path. Re-install the routes (Apache) or re-paste the generated snippet (Nginx). |
nginx 404 on /mcp, /mcp-public, or .well-known/… | Your location / block doesn't fall through to index.php. Add the try_files … /index.php?$query_string; fallback. |
405 Not Allowed when uploading (nginx) | A PUT to / is refused by nginx's static handler. The upload command uses POST --data-binary to index.php?ACT=… for exactly this reason — use the command the tool returns verbatim. |
413 on a large upload (nginx) | Raise client_max_body_size to match your PHP limits. |
500 “Upload failed unexpectedly.” from the upload URL | The web server's PHP has no writable temp directory. The add-on tries upload_tmp_dir, the system temp directory and then its own system/user/cache/mcp_server_for_ee folder for the streamed body, but EE's own file library still needs the system temp directory to process the saved file (its error reads Cannot create temp file at …, and the control panel's own uploads fail the same way). Turn on debug logging and retry; the mcp_upload.endpoint_error line names the directory. Point sys_temp_dir (or upload_tmp_dir) in php.ini at a folder the web server can write to, restart PHP, then re-mint the ticket with upload_file. |
| Assistant can't upload an attached image | The client has no shell (Claude Desktop, ChatGPT). Only shell-capable clients can upload. |
| “Status not permitted: open” | The member's role isn't assigned that status. Assign it under Members → Roles → Channel access, or use a status the role has. |
A “session busy” 503 | Two requests on the same MCP session overlapped — the endpoint serializes requests per session with a database lock. The second request waits up to 45 seconds for the first to finish; if it still hasn't, the second gets the 503 and is asked to retry. Avoid firing two tool calls at once on the same session. |
A tool the member should have is missing from tools/list | The member lacks the scope or the EE permission, or the tool is globally disabled on the Tools page. Reconnect the client after changing permissions so it re-fetches the list. |
-32603 generic error | Turn on debug logging and reproduce — the real exception is in the log. |
| A provider add-on's section on the Tools page appears on some page loads and not others | If the section is genuinely absent, the page prints “No installed add-ons currently register MCP tools…” in faint grey; check EE's Developer Log for a hook-fired-but-registered-nothing entry, and turn on debug logging for the per-definition rejection reasons. If results differ per refresh right after updating add-ons on a host with several PHP workers, a stale opcode cache in some workers is the usual cause: restart PHP. |
| Slow requests | Usually the environment, not the add-on (e.g. a network-mounted EE install). On a normal host, requests are tens of milliseconds. |
Getting help
Email [email protected] with your EE and add-on versions and (if relevant) a downloaded debug log.
Security model
- Two gates, both enforced on every call: OAuth scope, then native EE role permission. Scopes only ever narrow access; EE permissions are the ceiling.
- Reads are scoped like the publish form: a member without edit other entries on a channel reads, lists, searches and counts only their own entries there, exactly as the control panel opens only those for them. Revoking a client on Clients & Audit stops its live tokens at once, and rotating the signing keys stops every token.
- Dynamic client registration is open by design: any MCP client can register itself (RFC 7591), as the protocol expects; what protects the site is the sign-in itself (PKCE, the consent screen, the role and member rules on Access Control, the registered-scope ceiling). Revoke a client you do not recognise on Clients & Audit. An approval step for new clients is a possible later addition.
- Per-member tool visibility hides tools a member can't use, but hiding is cosmetic — each tool re-checks permission on every call.
- Every mutating call is audited (member, client, IP, action, target such as
entry:197, params hash — never raw params). Review the log regularly; a Super Admin's assistant can read the same log withlist_audit_log. - OAuth 2.1 with PKCE (S256), rotating refresh tokens, RS256 JWTs, keys encrypted at rest and rotatable. MFA via EE Pro (authenticator codes only).
- CSRF: protocol actions are intentionally CSRF-exempt (they're bearer-token or ticket authenticated); the human-facing OAuth pages use an HMAC-signed double-submit token.
encryption_keyrequired: the add-on refuses to derive signing/encryption material from a guessable fallback.- Debug log is content-free and access-guarded: secrets are redacted by key, tool results are logged as shape/size only (never content), and the log file has an unguessable per-install name so it can't be fetched directly even where
.htaccessis ignored. - Uploads run EE's full content validation (type sniffing, executable-extension blocks, per-destination limits, XSS clean).
- Restrict MCP to trusted roles on the Access Control page, with per-member deny or allow exceptions for the odd case. Configure CORS origins in the control panel when browser-based clients connect from known frontends.
- IP controls behind a proxy or CDN: EE only trusts
X-Forwarded-For/CF-Connecting-IPwhen the connecting proxy is listed in your site'sproxy_ipsconfig; keep that list to your real proxy addresses only. If it is over-broad, a caller can forge a new forwarded IP per request and sidestep the per-IP rate limits and the public blocklist (the public endpoint's site-wide ceiling still applies). - The public MCP endpoint is a separate, anonymous surface, off by default. When enabled, it exposes only open/dated/non-expired entries in channels (and fields) the administrator has explicitly allowlisted. No author or member data is ever returned. File fields are off unless you allowlist them, and then return only the file's public URL. It can never widen what the OAuth server exposes.
- Tools registered by other add-ons (see Extending) are opt-in per tool, run under the add-on's own central scope enforcement and audit logging, and can never appear on the public anonymous endpoint. Installing or updating a provider add-on never silently expands what an AI assistant can do — every new tool starts disabled.
- On Multi-Site Manager installs, every check above is scoped to the current site — see Multi-Site Manager.
Uninstalling
Remove the add-on from Developer → Add-ons in the CP. Uninstall drops all of the add-on's tables and removes its registered actions and hooks. Uninstall also removes the .htaccess block it installed on Apache; on Nginx, remove the pasted snippet from your server config yourself. The add-on's own cache folder (debug log) is removed too.
Versioning
1.0.0 is the first public release; the 0.x.y versions before it were the pre-launch series, and a site running one of them updates through EE's normal add-on update path (Developer → Add-ons → Update) with no reinstall. Later releases follow semantic versioning: a minor bump adds tools or capabilities, a patch bump fixes. The full change history is in CHANGELOG.md (shipped in the zip).
License & support
MCP Server For EE is commercial software. See LICENSE (shipped in the zip) or the commercial use license for terms — in short, one license covers one production site plus a related staging/dev site, and the software may not be redistributed or resold. Support and licensing questions: [email protected].