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

Composer is not required: the release zip ships with all dependencies bundled.

Installation

  1. Download the release zip and unzip it, or copy the mcp_server_for_ee folder into system/user/addons/mcp_server_for_ee. The folder already contains its vendor/ dependencies.
  2. In the EE Control Panel, go to Developer → Add-ons and install MCP Server For EE.
  3. 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.
  4. 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):

  1. General page → toggle Enable MCP server on, Save.
  2. Access Control page → either leave “Allow every member role” on, or pick the roles that may connect.
  3. Quick Start page → copy the ready-made config block for your client and paste it into that client's MCP config.
  4. 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.
  5. 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:

  1. 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.
  2. 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:

PagePurpose
GeneralEnable/disable the server, set the OAuth issuer URL, rate limit, CORS origins, debug logging, and rotate signing keys
Endpoints & RoutingCopy the ?ACT= endpoint URLs; install/remove clean-URL routing (Apache .htaccess, or a copy-paste Nginx/LiteSpeed snippet)
Access ControlChoose 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.
ToolsEnable 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 MCPOpt 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 StartCopy-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 & AuditReview 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

  1. The MCP client discovers authorization server metadata from the oauth_metadata action URL.
  2. The client registers dynamically (or uses a pre-registered client) via oauth_register.
  3. 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.
  4. The client exchanges the authorization code at oauth_token (PKCE, S256, required).
  5. 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.

ScopeAccess
mcp:readList/read channels, entries, categories, files
mcp:writeCreate/update/delete content where EE permissions allow
mcp:adminMembers, 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

ToolScopeEE permissionNotes
list_channelsreadchannel accessChannels the member can access
get_channelreadchannel accessField 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

ToolScopeEE permissionNotes
list_entriesreadchannel accessNewest 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_entryreadchannel accessBy 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_entriesreadchannel accessUp 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_revisionsreadchannel access + edit permission on the entrySaved 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_revisionreadchannel access + edit permission on the entryOne 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_entriesreadchannel accessTitle + 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_entriesreadchannel accessOptional 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_entriesreadchannel accessSite-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_entrywriteper-channel create + assigned statusesSee 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_entrywriteper-channel edit + assigned statusesA 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_entrieswriteper-channel edit + assigned statusesUp 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)writeper-channel deleteEnable on the Tools page

Categories

ToolScopeEE permissionNotes
list_category_groups, get_category_groupreadaccess to ≥1 of the group's channels 
list_categories, get_categoryreadaccess to ≥1 of the group's channelslist_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_categorywritecan_admin_channelsparentId must be a category of the same group (never the category itself or one of its descendants)

Files

ToolScopeEE permissionNotes
list_file_directoriesreadfile manager accessOnly 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_filereadfile manager + assigned destinationlist_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_filewritefile manager + upload + assigned destinationMints a one-time upload URL — see Uploading files
upload_file_inlinewritefile manager + upload + assigned destinationTiny files only (< 64 KB), base64
update_filewritefile manager + edit + assigned destinationRename in place (same extension, same folder) and/or edit title, description, credit, location — see Renaming a file
delete_file (disabled by default)writefile manager + delete + assigned destinationEnable on the Tools page

Members & admin (read-only)

ToolScopeEE permissionNotes
list_members, get_member, list_rolesadmincan_access_membersNo password/secret fields ever returned
get_site_settingsadminsystem prefsName/URL/index/site id only
get_public_usageadminSuper Admin onlyPublic-endpoint analytics (see Public MCP endpoint)
list_audit_logadminSuper Admin onlyThe 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:

  1. Call get_channel(channelId) first. It returns required_fields (the field names you must supply), statuses (the channel's statuses), category_groups (each with its categories inlined in CP order, plus required/allow_multiple), and for every field a value_format hint describing exactly what to pass. File fields also report which upload directory they accept files from (see Field contracts).
  2. Upload any files first (see below) so you have real file IDs.
  3. Call create_entry with channelId, title, status, the fields object, and optionally categoryIds.

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.

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).

onConflictBehavior
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.
replaceOverwrites 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.
failRejects 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:

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

The 7 public tools

ToolWhat it returns
list_public_channelsAllowlisted channels with the custom fields exposed on each (name, label, type)
list_public_entriesPublished entries in an allowlisted channel, optionally filtered by category (capped at 25 per page, limit/offset)
get_public_entryA single published entry by entryId, or by channelId + urlTitle
search_public_entriesTitle 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_categoriesCategory groups/categories reachable from allowlisted channels (names and descriptions only)
get_public_categoryA single category with its published entry count
count_public_entriesA 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

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:

{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.

KeyRequiredRules
addonyesYour add-on's installed shortname (lowercase, ^[a-z][a-z0-9_]*$). The host verifies it is actually installed.
nameyesLowercase, 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.
handleryes[ClassName::class, 'methodName'] — public, non-static, class no-arg-constructible.
scopeyesread, 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.
descriptionyesWhat the AI client reads. Write it for the model: what the tool does, what its ids mean, how it composes with other tools.
input_schemanoExplicit 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.
annotationsnoMCP 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

What the host enforces (and you cannot bypass)

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

When your tools don't appear

  1. 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.
  2. 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.
  3. 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).
  4. 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:

  1. Go to MCP Server For EE → General, turn on Enable debug logging, and save.
  2. Reproduce the problem (retry the failed connection, tool call, or sign-in).
  3. Click Download Debug Log next to the log file path, and attach the downloaded file to your support request.
  4. 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

SymptomCause / fix
“This form has expired” on a clean pathThe 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 URLThe 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 imageThe 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” 503Two 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/listThe 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 errorTurn 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 othersIf 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 requestsUsually 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

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].