MCP Server For EE – Changelog
Overview · Documentation · License
1.3.3 · October 28, 2026
No code changes.
Changed
- License terms. Support, Updates & Warranty in
LICENSEagain offers best-effort email support at [email protected] beside the updates a current license includes. The same terms as the website's license page and the other Multimediality add-ons. - Changelog format.
CHANGELOG.mdgroups every release under Added, Changed, Removed, Fixed and Security, with Upgrading notes where a release needs a step; the release notes themselves are unchanged.
1.3.2 · October 28, 2026
Changed
- Moving a site to a new URL. The manual's Installation section says that a move re-keys the add-on and every client signs in again.
- Credentials at rest. The OAuth flow section says refresh tokens, authorization codes and client secrets are stored only as hashes.
Fixed
- A site moved to a new URL can issue tokens again. The signing key is stored encrypted with a key derived from
encryption_keyandsite_url(Crypto::deriveKey()), so after a database copy to another URL (staging to live, a domain change) or anencryption_keychange it decrypted to an empty string,ensureKeyPair()kept it because the column was not empty, and the token endpoint failed (lcobucci: "Key cannot be empty").Settings::ensureKeyPair()now checks that the stored private key decrypts to a usable key and, when it does not, creates a new pair the same way Rotate Signing Keys does, but drops the previous public key (nothing can sign with it). The replacement only overwrites the row it found undecryptable, so two requests that notice at once end up with the same pair. It is recorded in the debug log (oauth.signing_key_regenerated), in EE's Developer Log, and as a notice on the General page (dismissable) saying that MCP clients must reconnect. The JWKS endpoint, discovery and the General page keep answering before and after. Covered byscripts/test_signing_key_rekey.php(re-encrypted and garbage keys, concurrent detection, HTTP against the deployed add-on; restores the site's real key pair).
Security
- OAuth bearer credentials are stored by hash, never raw. Refresh tokens were stored as their raw value (the
idofoauth_refresh_tokens), and authorization codes too, so anyone who could read the database (a backup, a dump, SQL injection elsewhere) got usable tokens. Both are now stored asOAuth\TokenHashids,h:plus SHA-256 (Crypto::hashSecret(), as client secrets already were; 62 hex characters, so the existingvarchar(64)column holds it), and looked up, rotated and revoked by hash; the raw value only ever goes to the client. The short-lived refresh-response cache (oauth_token_cache, kept two minutes so a retried refresh gets the same pair) held the whole new token pair as plain JSON; it is now encrypted with a key derived from the presented refresh token, which the database does not hold. A value copied out of the database is never accepted as a token. The Update step converts existing rows in place withSHA2(id, 256), so connected clients keep refreshing without signing in again; it is idempotent (the prefix marks converted rows, so it needs no version gate and never hashes twice), and until Update is clicked a raw row still refreshes, so copying the files in first breaks nothing. Access tokens are unchanged: they are keyed by the JWT'sjti, which is no credential on its own, and checked against the signature. Covered byscripts/test_refresh_token_hashing.php(HTTP against the deployed add-on: code exchange, rotation, the cache, forged hashes, rows from before the change, and the conversion run twice).
Upgrading
Replace the system/user/addons/mcp_server_for_ee folder with the new one and click Update on the Add-ons page. No schema change. Update hashes the stored refresh tokens and authorization codes in place, so connected clients keep their sessions and refresh without signing in again. A site whose database moved to a new URL (or whose encryption_key changed) gets a new signing key on its first token request, and its clients sign in again.
1.3.1 · October 28, 2026
Changed
- The manual is the hosted documentation. multimediality.com now renders this add-on's
README.mdandCHANGELOG.mddirectly, so the separately hand-written HTML page is gone. The manual took over every fact from that page that is still true (Quick start, the OAuth flow, the public endpoint's tools, rate limits and widget, the Extending guide, troubleshooting rows, security notes), and two stale claims were corrected: a failed upload does not always need a new ticket (a413 file_too_largereleases it), and not every control-panel page has a save action.
Fixed
- EE's permission service now describes the token's member, not a guest.
ee('Permission')is a per-request singleton that EE builds before any action runs (Core::run_ee()asksisSuperAdmin()to decide on the profiler), so on every MCP request — bearer token or upload ticket — it was built for the anonymous visitor, andMemberSessionBootstrap::apply()only updated the session. Every EE check that goes throughee('Permission')therefore answered for a guest. Seen as: a Super Admin'supload_fileof a Markdown manual was refused with "The file could not be written to disk", because EE's upload XSS filter (xss_check()), which skips Super Admins in the control panel, ran against them through MCP; and theee://member/permissionsresource reportedis_superadmin: falseand no permissions for every member.apply()now rebuilds the service exactly as EE does (session userdata, the member's permissions and roles, the site id) into the existing instance. This makes MCP as permissive as the control panel is for the same member, never more:PermissionGate's scope and permission checks are unchanged, and the public endpoint never bootstraps a member. Covered byscripts/test_member_permission.php(HTTP, against the deployed add-on).
Upgrading
Replace the system/user/addons/mcp_server_for_ee folder with the new one and click Update on the Add-ons page. No schema change.
1.3.0 · October 28, 2026
Added
- Publish tabs: Structure's tab (and any module's) through MCP. An assistant could not set what Structure keeps on its own publish tab (a page's parent, URL segment, template, Hide from nav?, listing channel), so a page created over MCP was stuck on the channel's default template until someone opened the CP.
Services/PublishTabsreads the tab fields EE itself adds to the entry model ({module}__{field}, from each installed module'stab.{module}.php), so it covers Structure 5 and the Structure bundled with EE alike, EE's Pages module, and any other module tab, never keyed to one add-on's field names.get_channellists them undertabswith each select's options as the publish form offers them to this member;get_entry(andget_entrieswithoutfields) reports an entry's values undertabs;create_entry,update_entryand eachupdate_entriesitem taketabs(a merge), refusing before save a select value the dropdown would not offer, a text value over its max length, or an unknown tab or field. Changes are reported:would_change.tabson a dry run,tabs_changedon the result, since Structure'suri/parent_idare the page's published URL. Covered byscripts/test_publish_tabs.php(30 checks on the multimediality dev site, one real write put back); verified there over HTTP by setting the Home, Add-ons and Tools pages' templates, after which /add-ons/ and /tools/ render their cards.
Upgrading
Replace the system/user/addons/mcp_server_for_ee folder with the new one and click Update on the Add-ons page. No schema change.
1.2.0 · October 28, 2026
Added
- A Claude Desktop extension for local sites.
desktop-extension/is a.mcpbbundle (npm run build:extension→dist/mcp-server-for-ee-local-<version>.mcpb, validated and packed with Anthropic'smcpbtool) that gives a developer the Claude Desktop bridge without a config file or a Node install: two settings (the MCP server URL from Quick Start, and a development-only "trust the site's self-signed certificate" switch), and Claude Desktop supplies Node. The entry point derives the site's Connected page from the MCP URL and passes it as mcp-remote's--auth-success-url, adds--allow-httpfor a plain http site, and refuses a missing or malformed URL with a plain message. It runs the bridge in its own process (Claude Desktop's built-in Node is the app itself in Node mode, so a spawned child would be a second copy of the app) and appends a short log tomcp-server-for-ee-local.login the system temp folder for support. mcp-remote (MIT) is bundled with its notice; the add-on's version is stamped into the manifest at build time. The add-on ships the bundle in itsassets/folder, and the Quick Start page offers Download Claude Desktop extension for this site:Services/DesktopExtensioncopies the bundle and rewrites its manifest for the site (a name from the site's host, the site's display name, the MCP server URL filled in), so each site installs as its own extension and several development sites connect side by side. The server'sinstructionsnow open by naming the site and its URL, so an assistant connected to several sites can tell them apart. The button needs PHP'szipextension and says so when it is missing. The manual's Claude Desktop section describes the install.
Upgrading
Replace the system/user/addons/mcp_server_for_ee folder with the new one and click Update on the Add-ons page. No schema change. The .mcpb file at the zip root is for Claude Desktop, not for the server; it needs no installing on the site.
1.1.0 · October 28, 2026
Added
- A "Connected" page for sign-ins that end in a bridge.
/oauth/connectedis a themed page (light or dark with the system setting, like the other OAuth pages) saying the assistant is now connected to the site and the window can be closed. mcp-remote, the stdio bridge Claude Desktop needs for a local, staging or intranet site, otherwise shows its own plain "Authorization successful" text after the sign-in; given--auth-success-urlit sends the browser here instead. The page is GET-only and served directly by the clean-URL dispatcher (no action row, no 307, no?ACT=form — the bridge refuses a URL with a query string): the.htaccessblock gains an internal rewrite for it, and the Nginx snippet'stry_filesfallback already covers it. - Quick Start: Claude Desktop with a site it cannot reach. Custom connectors connect from Anthropic's servers, so a local or private site needs a local server entry instead. The Claude Code & Claude Desktop panel gains a ready
claude_desktop_config.jsonblock (npx mcp-remote <MCP URL> --auth-success-url <Connected page>) with the Node.js and self-signed-certificate notes; the manual's Claude Desktop section has the same.
Upgrading
Replace the system/user/addons/mcp_server_for_ee folder with the new one and click Update on the Add-ons page; an installed .htaccess block is refreshed by the update and gains the /oauth/connected rule. No schema change.
1.0.5 · October 28, 2026
Fixed
- Clients built on the current MCP TypeScript SDK can sign in again. The protected-resource metadata (the
oauth_resource_metadataaction and/.well-known/oauth-protected-resource, plus theWWW-Authenticatechallenge's document) named the authorization server by theoauth_metadataaction URL. RFC 9728 wants the issuer identifier there, and the TypeScript SDK (mcp-remote 0.15.2, and the clients that embed the same SDK) now checks that the discovered metadata'sissuerequals that value (RFC 8414 §3.3), so the sign-in stopped with "Issuer mismatch in authorization server metadata" before a browser ever opened. Both documents now give the issuer URL (the General page setting, or the site URL). Nothing else changes: those clients always derived/.well-known/oauth-authorization-serverfrom the origin and never fetched the?ACT=form, so the clean-URL requirement is the same as before. Verified on dev with mcp-remote over stdio: dynamic registration, browser sign-in, code exchange, refresh, handshake, tools/list and tool calls, then a silent reconnect from cached tokens.
Upgrading
Replace the system/user/addons/mcp_server_for_ee folder with the new one and click Update on the Add-ons page. No schema change.
1.0.4 · October 25, 2026
Changed
- Licenses run for a year and renew. The LICENSE gains a "License Term & Renewal" section: each license includes a year of updates from purchase and renews through the ExpressionEngine store; an expired license never disables the add-on or touches a site's front end, ExpressionEngine only shows an "expired" notice in the control panel. The add-on itself never reads its license status. The manual and the online docs say the same.
Fixed
- Public usage charts with little data. "Calls per day (last 30 days)" and "Rate-limited & blocked requests (last 7 days)" only received the days that had traffic, and the chart divides its width among the days it gets, so a single active day was drawn as one block filling the whole chart. Both series now carry every day of the window, with 0 for a quiet day, so the charts draw 30 and 7 bars. Each chart now also says what it shows: a summary line above it ("2 calls in the last 30 days. Busiest day: 11 Oct (2)."), the peak value against a faint top line, and the first, middle and last dates under their bars; a window with nothing to show is just its sentence.
get_public_usagereturns the same filled series (days without calls appear with 0); with no logged calls at all it still returns an empty list.
Upgrading
Replace the system/user/addons/mcp_server_for_ee folder with the new one and click Update on the Add-ons page. No schema change.
1.0.3 · October 25, 2026
Brings the add-on in line with ExpressionEngine's add-on development checklist. No schema change; nothing a client or an editor sees in English changes.
Changed
- Unix line breaks in the release zip. Text files built on Windows carried CRLF line endings; every text file in the zip now has LF, and the zip is otherwise identical.
- Front-end text is XSS-cleaned before it is stored, with ExpressionEngine's own filter: the name a registering client gives itself, and the search text, user agent, client name and client version the public endpoint logs. Ordinary values are stored unchanged, and every page still escapes them on output.
- The OAuth sign-in, consent, MFA, error and completion pages, the public widget and the scope descriptions read their text from the language file, like the control panel, so they can be translated. The English wording is unchanged.
Upgrading
Replace the system/user/addons/mcp_server_for_ee folder with the new one and click Update on the Add-ons page.
1.0.2 · October 19, 2026
Documentation only; no code changes. Updating is optional: only the control panel Manual text changes.
Changed
- Clean paths and redirects. The manual says a clean path reaches the add-on through a 307 that the client must follow, and that a client which does not (some hosted connectors) needs the
?ACT=URL the Quick Start page, server card and widget always give. - Integrator guide and agent skill: a section on holding a provider tool to the host's own rules (never more than the control panel allows, reads included; current site only on Multi-Site Manager), with matching lines in the skill, the AGENTS snippet and the checklist.
1.0.1 · October 19, 2026
Found on the first install on a site that had never had the add-on: a site with no .htaccess could not be connected to, and nothing said so.
Changed
- A troubleshooting row for
fetch failedfrom Cursor, Claude Code or VS Code: on a local or staging site with a self-signed certificate the request never reaches the add-on.
Fixed
- Clients can find the server on a fresh site, and the control panel says when they cannot. Most MCP clients find the OAuth server at
/.well-known/oauth-authorization-serveron the site root, and a site with no.htaccess(or an Nginx config without theindex.phpfallback) answers that path with the web server's own 404, so a client cannot sign in although every?ACT=URL works. The General page now checks that path over HTTP while the server is on and, when it does not reach the site, says so and links to Endpoints & Routing; that page shows the result with a Check again button. On Apache, Install Public Routes now also works on a site with no.htaccess(the button reads Create .htaccess and Install Routes and the file holds only the add-on's block in amod_rewriteguard; Remove deletes it again), and in a non-EE.htaccessthe block goes at the top in its own guard instead of being appended unguarded. - Uninstall leaves a site as it found it. Removing the routes (or uninstalling) deletes a
.htaccessthe add-on created when the block was all it held. Verified on a fresh install: after uninstalling, no rows, tables, files or cache folder mentioning the add-on remained. - Rotate Signing Keys says what it does. The confirmation, the manual and the online docs said connected clients must sign in again after a rotation. Rotation stops every access token signed with the old key; clients renew theirs with their refresh token without signing in again. Revoking a client on Clients & Audit is what forces a new sign-in.
Upgrading
Replace the system/user/addons/mcp_server_for_ee folder with the new one and click Update on the Add-ons page. No schema change. A site that already connects needs nothing else; the General page now tells you if one does not.
1.0.0 · October 12, 2026
First public release. MCP Server For EE turns an ExpressionEngine 7.2+ site into a Model Context Protocol server with its own OAuth 2.1 authorization server, so AI assistants (Claude, Cursor, VS Code Copilot, ChatGPT, Codex, any MCP client) sign in with an ExpressionEngine member account and work with the site's content through structured tools, never beyond what that member could do in the control panel. The 0.x entries below are the pre-launch history of everything summarised here.
ExpressionEngine 7.2 or newer; PHP 8.2 or newer with the openssl and fileinfo extensions; a non-empty encryption_key in the EE config; ExpressionEngine Pro only for members who sign in with MFA. The release zip ships with all dependencies bundled — no Composer step.
The three review passes between 0.24.0 and this release are written up in full under Pre-release review notes below; this is what a tester of a 0.24.x or 0.25.x build will notice.
Added
- 34 authenticated tools for channels, entries, categories, files and admin reads, each gated by an OAuth scope (
mcp:read,mcp:write,mcp:admin) and the member's native EE role permissions; a scope never widens access.tools/listis filtered per member, every mutating call is audited, and any tool can be switched off globally (delete_entryanddelete_fileship off). - Writes that never exceed the publish form.
get_channelreports every field's contract (a file field's upload directory and image-only flag, a relationship field's channels/statuses/categories/authors, a text field's length, an option field's choices, a Grid's columns and row limits, a Fluid field's allowed children) and the entry tools refuse a mismatch before anything is saved; Grid, File Grid and Fluid fields read and write like any other field, and write responses are a post-commit re-read. - Built for bulk passes.
fieldFiltersonlist_entries/count_channel_entries/search_entriesselect entries by a field's state,get_entriesreads up to 50 entries in one call,update_entriesapplies up to 25 updates with a per-item result,returnFieldstrims write responses,dryRun: trueruns every check without writing, andlist_entry_revisions/get_entry_revisionread an entry's saved history. - Large-file uploads through a single-use upload URL the client's shell transfers to, so file bytes never pass through the model;
update_filerenames a file in place and edits its metadata,list_filesfinds a file by name. - OAuth 2.1 authorization server: PKCE (S256) required, dynamic client registration, rotating refresh tokens, RS256 JWTs with keys encrypted at rest and rotatable from the control panel, EE Pro MFA (authenticator codes) at sign-in.
- Audit log of every mutating call naming what it touched (
entry:197), filterable on the Clients & Audit page and readable over MCP by a Super Admin withlist_audit_log; per-IP rate limiting; opt-in, secret-free debug logging with a one-click download. - Optional public endpoint: a separate, anonymous, strictly read-only surface (7 tools) exposing only open, dated, non-expired entries in allowlisted channels and fields, with its own rate limits, IP blocklist, usage log and analytics, a front-end widget and
.well-knowndiscovery. Off by default. - Plugin API for other add-ons: a versioned
mcp_server_for_ee_register_toolshook to contribute tools (scope-gated, audited, opt-in per tool) and amcp_server_for_ee_register_field_typeshook to describe a fieldtype's value and report back after each save (field_reportson the write result). Yumpu Embed is the first add-on built on it. - Control panel: seven Super Admin pages (General, Endpoints & Routing, Access Control, Tools, Public MCP, Quick Start, Clients & Audit), with copy-paste client configs and an optional clean-URL installer for Apache and a generated snippet for Nginx/LiteSpeed.
- Serves both MCP protocol eras from one URL (the 2026-07-28 revision and the
initialize-handshake revisions 2025-03-26 through 2025-11-25); Multi-Site Manager aware throughout. - Member Exceptions on the Access Control page: a per-member deny or allow that overrides the role rule, picked from a list of the site's members. Roles stay the intended way to control access.
urlTitleoncreate_entry,update_entryand eachupdate_entriesitem, with the publish form's rules; a derived slug that is already taken is refused (naming the holder) instead of silently suffixed;get_channelreports the wholeurl_titlecontract; a changed slug is reported asurl_title_changed(andwould_change.url_titleon a dry run).
Changed
- Result shapes:
list_categoriesreturnscount; the search and count tools'querytakes 3 to 100 characters on both surfaces;update_entriesdry runs returnwould_update;list_audit_logpages withbeforeId/afterId;get_channelmarks a Grid or Fluid fieldafter_write: truewhen a child is a reporting provider type. - Packaging: the release zip ships without the composer packages' repository files, with sane file modes, and the build refuses to run when the version files disagree. Dependencies are at their newest compatible versions;
composer auditis clean.
Fixed
- Files: an uploaded file's
file_sizeis the exact byte count and every file record carriesmodified_date;update_filerefuses a rename to a name EE's upload path would have stored differently; an upload's completion row in the audit log names the client that minted the ticket. - Entries and categories:
create_categoryworks (it never saved before: the category URL title is now derived from the name and kept unique in the group);search_entriesand thequerycounts search every free-text type EE7 ships (rte,email_address,markdownwere missed); a Super Admin can no longer save a status the channel does not have; option fields pre-populated from another channel validate against the full distinct list;create_entryrefuses a missing channel instead of failing, and on a Multi-Site Manager install sets the entry's site; channels and upload destinations of another site are refused for everyone. - Public endpoint: file fields render on EE7 (the stored
{file:ID:url}form); a search containing_or%matches; the 3–100 character bound counts characters. - Install and control panel: the update path no longer installs the clean-URL
.htaccessblock unasked; a folder copied withoutvendor/no longer takes the site down; uninstall removes the add-on's cache folder; Enter in a General-page field saves instead of clearing the debug log; the manual renders in the control panel as the add-on's Manual, with code samples as code blocks.
Security
- Security. Upload tickets and MFA challenges accept only the add-on's own encrypted form (a plain or legacy payload is refused); revoking a client on Clients & Audit also revokes its live access and refresh tokens; reads follow the publish form (a member without edit other entries on a channel reads, lists, searches and counts only their own entries there); banned and pending members cannot complete sign-in; the 500 bodies of both MCP endpoints carry one fixed message and never a trace.
Upgrading
A site running any 0.x version updates through EE's normal add-on update path (Developer → Add-ons → Update): no uninstall, no reinstall, settings, clients, tokens and the audit log are kept. From here on a minor bump adds tools or capabilities and a patch bump fixes.
0.24.0 · September 12, 2026
Added
- Provider "after write" reports on the entry tools. A fieldtype provider can now report back on the write result itself: one optional key,
after_write, onApi\FieldTypeRegistrar::addFieldType()(field-type API version stays 1; only optional keys may be added within a version) names a public staticafterWrite(array $context): array, validated like a tool handler (class and method must exist, method public static; an invalid callable rejects the definition with a DebugLog reason).create_entry,update_entryandupdate_entriescall it after a successful, non-dry-run save, once per touched top-level field whose type carries the hint — for a Grid or Fluid field, once per provider type per top-level field (with the top-level field's name), not per cell — with{tool, entry_id, channel_id, field_name, field_type}, and attach the returns asfield_reportskeyed by field name besideentry(inside eachupdate_entriesitem), present only when a provider returned a non-empty array. Same containment as a tool handler (Services/FieldWriteReporter): a throwing provider, a non-array, non-JSON or over-4 KB return is dropped and logged and never changes the write's outcome. Providers must keep it cheap (local state only, no remote calls).get_channelmarks such fieldsafter_write: trueso a client knows a report will come. Motivation: Yumpu Embed 1.3.0 gave clients a per-family Yumpu rate-limit ledger (yumpu_embed_quotaand aquotablock on its tools), but an entry save can itself cost one Yumpu call (a value fromget_upload_progresscosts oneGET documenton its first save), which the client only saw in its next Yumpu tool result; with this, Yumpu Embed 1.4.0 returns the samequotablock on the entry write. Guide: new "Reporting back after a save" subsection, checklist line and version footnote indocs/extending-mcp-tools.md; tool descriptions, server instructions and the tool table mentionfield_reports.scripts/test_plugin_api.phpgains a fake provider covering the registrar validation, the report onupdate_entry/update_entries/create_entry, an omitted field, a title-only update, dry runs, a throwing / non-array / empty / oversize provider, an invalid callable, and the once-per-field rule on the scratch Grid and Fluid fields.
0.23.0 · September 11, 2026
Added
- The audit log records what each call acted on. A new nullable
targetcolumn onmcp_server_for_ee_audit_log(entry:197,file:1181,category:12,category_group:2), indexed, filled by every built-in mutating tool:update_entry,update_entries(one row per item),delete_entry,update_category,update_file,delete_filename their object;create_entry,create_category,create_category_group,upload_fileandupload_file_inlineare logged before the write under their container (channel:3,category_group:2,directory:6; a refused attempt is still recorded there) and pointed at the new id once it exists; the upload ticket'supload_file_completerow names the file. External tools have no single target and stay NULL, as do rows from before this version. The Clients & Audit page shows the target beside the tool name, each one a link that filters the table to that target, resolves the member column to the screen name (linked to the member profile, id kept beside it) and each target to its name (entry title, file title or name, category, group, channel, upload destination) linked to that object's CP page, one query per kind per page, and has a filter box (entry:197) for "what touched this entry", the question the production run could not answer. Values are reduced to identifier characters and 64 bytes before storage and display. list_audit_log: the same log over MCP.list_audit_log(limit?, offset?, target?, toolName?, memberId?)returns the rows newest first withmember_nameandtarget_nameresolved (the same one-query-per-kind resolvers the CP page uses, now inServices/AuditLogger),countfor the filter so a client can page, and never the parameters, only their hash. Admin scope plus Super Admin, exactly the page's gate (PermissionGate::canViewAuditLog()). Lets an agent answer "what touched entry 197" itself and pace a resumed run against its own earlier writes. Advertised to Super Admins with admin scope; on the Tools page; in the server instructions and tool table. The column is added by the add-on update (ensureAuditLogTargetColumn(), also on fresh installs) and the logger keeps working without it on a site that has copied the files but not yet run the update.scripts/test_plugin_api.phpadds the column when missing (mirroring the migration) and checks the targets onupdate_entry/update_entries, that dry runs log nothing, the filteredrecent()and the cleaning.
0.22.0 · September 11, 2026
Fourth and last release from the agent-exercise review: diagnosis and dry runs.
Added
- Entry revisions:
list_entry_revisionsandget_entry_revision. EE keeps a revision on every data-changing save for channels with versioning on (Developer → Channels → edit → Settings → Versioning), and MCP writes produce them like CP saves do (ChannelEntry::saveVersion()snapshots the model when there is no form POST).list_entry_revisions(entryId, limit?, offset?)lists them newest first withversion_id,author_id,version_dateand the totalcount, plus the channel'sversioning_enabled/max_revisionsso a client knows whether history exists;get_entry_revision(versionId)returns one with the native properties and every custom field value as of that save, keyed byfield_namelikeget_entry(Grid/Fluid/relationship values in the raw captured form, markedcomplex; the CP'scategories[...]POST flattened tocategory_ids). Both need edit permission on the entry, the CP's own rule for its Revisions tab (PermissionGate::canEditEntry()), and answer a missing and a hidden revision with the same message.version_datais unserialized with classes disallowed.get_channelnow reportsversioning_enabledandmax_revisions. This is the tool for "this field is empty and I do not know why", which the production run could not answer. The guide states the three limits a reader needs: history exists only from when the channel setting was switched on,version_idis a site-wide sequence (gaps are normal), andmax_revisionsrolls the oldest off per entry, so it is a shallow safety net, not an audit log. Read scope; advertised to members with channel access; on the Tools page. dryRun: trueoncreate_entry,update_entryandupdate_entries. 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 gives; success returns{dry_run: true, valid: true, ...}withwould_changeon update ({from, to}for each touched title, status, field and the category ids,fromasget_entryreports the stored value andtoas the fieldtype would receive it after the contracts normalised it) orwould_createon create (with the derivedurl_title).update_entriesdry-runs every item and reportsvalid/failed. Nothing is written and no audit row is logged; a dry run is not a reservation, the real call checks again. The harness proves the field table,edit_date, the entry count and the revision list are unchanged after each kind of dry run.queryonlist_files. A case-insensitive contains-match onfile_nameortitle(2+ characters) that searches the whole destination, subfolders included (folderIdis ignored and reported as null; each row carries itsdirectory_id), so a client can match an external asset to a file without paging a directory. Same clamps and destination gate as the plain listing.
Server instructions, tool table, two new guide sections ("Dry runs", "Revisions") and client checklist steps 25–28. scripts/test_plugin_api.php turns versioning on for the scratch channel (a dev-only fixture) to cover the revision tools.
Fixed
- Harness: an interrupted
scripts/test_plugin_api.phprun could leave every external tool disabled on the dev site. The harness swapsenabled_external_toolsfor its own test tool names and restores the original in afinallyblock, but a fatal that exits inside EE's error handler skips that, and the next run then faithfully "restored" the stale test names, so the Yumpu tools vanished fromtools/liston dev. The harness now refuses to treat a value holding its test names as the original (warning on stderr) and also restores at shutdown. Dev only; nothing ships in the add-on for this.
0.21.0 · September 11, 2026
Third release from the agent-exercise review: batch writes. The prod run made 165 single update_entry calls; the same pass is now seven calls.
Added
update_entries: up to 25 entries in one call.update_entries(updates, returnFields?)takes a list of{entryId, title?, fields?, status?, categoryIds?}items and applies each exactly asupdate_entrywould (the same merge semantics, field contracts, per-channel edit permission, status gate, EE validation, post-commit re-read), through one shared code path, so nothing the batch does is more permissive than the single tool. The result is per item, in order:{entry_id, ok: true, entry}or{entry_id, ok: false, error}with the messageupdate_entrywould have given, plusupdatedandfailedcounts. A failed item never stops the rest and nothing spans items (no cross-item transaction, which EE could not give across fieldtype tables anyway), so a partial batch is visible and retried byentry_id; a failure below the tool layer (a fieldtype or hook throwing inside save) is reported as that item's failure with a hint to re-read the entry, and logged. Every item's shape is validated before anything is written: duplicate ids (named with both positions), an item carrying no instruction (a value identical to the stored one is still written and bumpsedit_date; values are not compared first, since that would mean reading every entry), a non-integer id, a malformed item, more than 25 items (refused with the count, not clamped, asget_entriesdoes) and an empty list.returnFieldsapplies to every returned entry, with unknown names listed once at the top level. One audit row per item underupdate_entries. The cap is 25 rather thanget_entries' 50 because a provider fieldtype'ssave()can call out per entry (Yumpu Embed enriches and syncs a document on each save) and a batch has to finish inside a client's request timeout. Advertised to the same members asupdate_entry; described on the Tools page, in the server instructions, the tool table and the entries guide.scripts/test_plugin_api.phpruns a mixed batch (one applied, one unknown id, one contract refusal), checks order, counts, the stored value, theget_entryshape,returnFieldsand the hoistedunknown_fields, and the six up-front refusals.
0.20.0 · September 11, 2026
Second release from the agent-exercise review: the query side. The dry run that cost 173 reads ("which entries in this channel still have an empty flipbook field?") is one call, and its count is the progress meter after an interruption.
Added
fieldFiltersonlist_entries,count_channel_entriesandsearch_entries. An optional list of up to five{field, op, value?}filters, AND-combined with the existing category/status/query filters:fieldis afield_namefromget_channel,opisempty,not_empty,equalsorcontains. The rule is read from the field's type, never from a site's field names (Services/EntryFieldFilter): scalar-column fields (text, textarea, rich text, url, email, select/radio/checkboxes/multi_select, date, toggle, file, provider types such asyumpu_embed) take all four, against the per-field table or, for fields still onlegacy_field_data, theexp_channel_datacolumn —emptyis no row, NULL or blank after trim,equalsthe whole stored string,containsa LIKE (two characters or more); a file field'sequalstakes a file id (or the stored{file:ID:url}) and matches the id prefix; relationship fields takeempty/not_empty/equalswith a related entry id, againstexp_relationshipsfor the top-level placement; Grid, File Grid and Fluid fields takeempty/not_emptyagainst their own row tables (their field column holds only searchable text, which Fluid does not fill, soequals/containsare refused there rather than answered wrongly). Every filter runs in SQL as an EXISTS against the entry, so the listing keeps its order and paging and the count needs no page walk.get_channelnow reportsfilterable_opsper field so a client can plan a filter without a refused call; a refusal names the field, its type and the ops it takes (unknown field, unknown op, op not available on that type, missing or non-scalar value, a non-id on a relationship or fileequals, acontainsunder two characters, more than five filters, a malformed item). Server instructions and tool table updated.scripts/test_plugin_api.phpcovers each family on the scratch channel (text equals/contains/not_empty/empty, two filters ANDed, status combined, file empty/not_empty/equals by id and by stored value, relationship not_empty/equals/empty, Grid empty, the count equal to the list,search_entrieswith a filter,[]filtering nothing) and the nine refusals.returnFieldsoncreate_entryandupdate_entry. The same shape asget_entries'fields: omit for every custom field,[]for none (the entry'sfieldskey is then an empty list, and the native propertiesentry_id,title,status, dates and categories are all that comes back), a list for just those; names the channel does not have come back inunknown_fieldsat the top level of the result besideentry, whereget_entriesputs it too. The response is still the post-commit re-read from 0.19.1, trimmed before the Grid/Fluid/relationship/file lookups run. The prod run's single largest context cost was 165 full records with long HTML fields returned from writes it never needed to read.
0.19.1 · September 11, 2026
First of the releases from the agent-exercise review (an agent linked ~165 Yumpu embeds on a production books channel through MCP; the full triage is in docs/todo.md).
Changed
create_entry/update_entryresponses are a post-commit re-read. The response used to be built from the model that had just been saved, which holds what was handed to the per-field tables (each fieldtype'ssave()return) rather than what is in them afterpostSave()and after-save extension hooks ran. Both tools now re-fetch the entry by id aftersave()and format that, so a write response is evidence of the stored state rather than a claim about it. Same shape, same fields, and now the same values as a followingget_entry(one visible difference: a text field with the integer content type comes back as a number, asget_entryalways returned it, where the saved model still held the string); the harness checks the response'sedit_dateand field value against the tables after a write and that it equalsget_entry's.get_entriesdocumentsfields: []. An empty list was already handled as "no custom fields, native properties only" (the cheapest shape for a metadata audit, which is how the prod run used it), but nothing said so and a caller could read[]as "all fields". The tool description, the parameter schema and the tool table now state it.
Fixed
edit_datewas1on every entry fromget_entry,get_entries,list_entriesandsearch_entries. EE typesedit_dateas atimestampcolumn, which the model hydrates as aDateTime, and the formatter cast it with(int), which is1for any object;entry_dateis typedintand was always right, and the write tools only looked right because they assigntime()before formatting. Every date now goes through one helper (DateTime→getTimestamp(), else(int)), on the OAuth entry tools and on the public endpoint's model-formatted path (PublicEntryQuery::formatModel(); its raw-SQL listing was unaffected). With this an audit pass can tell which entries a run touched from theiredit_datealone. Reproduced on dev before the fix;scripts/test_plugin_api.phpnow comparesget_entry's dates with thechannel_titlesrow.
0.19.0 · September 11, 2026
Added
get_entries: up to 50 entries in one call.get_entries(entryIds, fields?)returns each entry inget_entry's shape (every field value, resolvedfileobjects, Gridrows, Fluiditems, related ids) in the order the ids were given, so a channel of a few hundred entries is a handful of calls instead of oneget_entryper entry serialised through the session lock. The optionalfieldslist (field names fromget_channel) limits which custom fields are included, and the fields left out are skipped before their Grid/Fluid/relationship/file lookups run, which is what makes an audit-style pass ("does every entry have an SEO description") cheap: on the dev site, 50 entries take about 1.1 s and 113 KB with every field, 0.35 s and 12 KB with one. Ids that do not exist or are not accessible are listed inskippedwith the same single reasonget_entrygives, never inentries, so a hidden id is not distinguishable from a missing one; field names no fetched entry's channel has are reported inunknown_fields; duplicates are collapsed; more than 50 ids is refused with the count rather than clamped, so a client pages deliberately. Read scope and the per-channel access check per entry, exactly asget_entry. Advertised to the same members as the other entry read tools; documented in the server instructions and the tool table; covered inscripts/test_tools_cli.php(order, duplicate, skipped id, item identical toget_entry's, thefieldsfilter with an unknown name, the four refusals).
0.18.0 · September 9, 2026
Added
update_file: rename a file in place and edit its metadata.update_file(fileId, fileName?, title?, description?, credit?, location?)changes only what is passed. It needscan_edit_filesplus an assigned destination, the CP's own rename/edit gate (PermissionGate::canAccessUploadDestination(…, 'edit')), and answers "File not found" for a file the member could not browse. A rename is confined to what a rename in the file manager can do: a bare name, cleaned like an upload (spaces to underscores;requested_file_namereports the change), the current extension kept (so the destination's type rules and EE's executable-extension checks cannot be sidestepped), the same destination and folder, refused when the name is taken on disk or in the table; a case-only change steps through a temporary name, since EE's filesystem layer refuses a destination that "exists" on a case-insensitive disk. The title follows the new name when it was only ever the file name and notitleis passed (Filemanager::rename_file's rule); the old name's thumbnails are dropped so EE regenerates them; the record is validated before the disk rename, and a failed save renames the bytes back.file_idis kept, so entries that reference the file are unaffected. Before renaming,Services/FileReferenceScannerlooks for the one reference a rename breaks, the legacy{filedir_N}nameform (whatfile_manager_compatibility_modesites still store, and what content never re-saved since EE 6 may hold), across the same storage EE's own File Usage utility scans (every channel field column, every Grid column,categories.cat_image), and the rename is refused with the entry/category ids and field names when any is found, since EE 7 rewrites that form to{file:ID:url}on the next entry save and the fix belongs there, not in a file tool; metadata-only edits are never blocked. The result carries ausageblock (entry and category ids fromexp_file_usage) so the caller knows what the file touches, for instance which Yumpu Embed values to rebuild (their storedfile_nameis a label only;{field:pdf_url}resolves by id). Every file row (list_files,get_file, uploads,update_file) now also carriesdescription,credit,locationandusage_count. Advertised intools/listonly to members withcan_edit_files; audited asupdate_file; described on the Tools page and in the server instructions. Newscripts/test_file_update.phpuploads a probe, renames it (cleaning, auto-title, case-only, disk and table checked), then covers the refusals: nothing to update, path in the name, extension change, name taken, a planted legacy{filedir_N}namereference in the scratch channel (with a near-miss that must not block), read-only scope, unknown id; theusageblock against a plantedexp_file_usagerow; explicit title, empty title reset, metadata-only edits.
0.17.0 · September 6, 2026
Added
get_channelinlines each category group's categories. Every entry incategory_groupsnow carriescategory_count,categories_truncatedandcategories— the group's categories as{category_id, group_id, name, url_title, parent_id}in the order the CP shows them (parents first, then the group's custom order or alphabetical) — beside the existingrequired/allow_multiple, so a client can choosecategoryIdsforcreate_entry/update_entryfrom the one call it already makes instead of alist_categoriesround trip per group. A group with more than 200 categories is not inlined: it reports the count withcategories_truncated: true, andlist_categories(no limit) returns the same rows. One sharedServices/CategoryListorders and shapes the rows forget_channel,list_categoriesandget_category, solist_categoriesnow also returnsurl_titleand CP order.scripts/test_plugin_api.phpchecks the inlined rows againstlist_categoriesand the CP order, the truncation path, and a channel without groups.
Changed
- Tool surface, from the agent-experience review. Every mutating tool now declares its annotations (
create_*and the uploadsdestructiveHint: false;update_entry/update_categorydestructiveHint: true, idempotentHint: true;delete_entry/delete_filedestructiveHint: true), so a client that gates on hints no longer treats a create as destructive.update_entrystates its semantics in its description and schema: a merge per top-level field, except that a Grid or Fluid array is that field's complete set of rows/items.list_entriesandsearch_entriestake an optionalstatusfilter (and say their fixed order: newest first, and by title).get_entryalso acceptschannelId+urlTitle.get_public_category's parameter iscategoryIdlike every other category tool (wascatId). Pagination parameters describe their clamps in the schema (entries 1–100, files 1–200, members 1–100, categories 1–1000). Permission refusals name the EE permission and where to grant it; the upload tools' "destination not found" points atlist_file_directories; the channel-fields resource saysget_channelis authoritative for writes and the channels resource carries the description too;get_channeldocuments the settable native keys (entry_date,expiration_date,sticky); astatusis stored as the channel spells it even when passed in another case; the admin tools name their scope and permission; the server instructions cover the get_channel contract loop, the merge rule, andget_entryby slug.list_file_directoriessaysdirectory_idandupload_location_idare the same value. mcp/sdk0.7.1 → 0.8.1, and the authenticated endpoint now serves the 2026-07-28 protocol revision (the public endpoint stays on the handshake era; see Security below). The SDK's transport classifies every request by era and routes it: a request carrying the 2026-07-28 per-request_metaenvelope goes to the SDK's stateless leg (noinitialize, noMcp-Session-Id, a throwaway in-memory session, SEP-2243Mcp-Method/Mcp-Nameheaders checked against the body,structuredContenton every tool result), whileinitialize-handshake clients (2025-03-26 … 2025-11-25) keep usingDbSessionStoreand the per-session lock exactly as before. A client askinginitializefor 2026-07-28 is counter-offered 2025-11-25. Two wiring changes were needed:ProtocolVersionMiddlewareis no longer in either endpoint's custom middleware list (the transport applies it to handshake-era traffic itself after classification; listed before that it rejected every modern-era request), and the public server card now advertises2026-07-28. Behaviour changes inherited from the SDK: an unknown tool ontools/callanswers-32602on every revision (was-32002), and a list-shaped tool result is sent asstructuredContentonly on 2026-07-28 (older revisions keep the JSON incontent).composer auditclean. Newscripts/test_protocol_eras.phpprobes both eras over HTTP against the dev site (handshake initialize/session/400 on a bad version/counter-offer; modernserver/discover,tools/list,tools/callwith structuredContent,-32020on a missingMcp-Method,-32602on an unknown tool, 401 withWWW-Authenticate; the public endpoint in both eras), 20/20; every existing harness is green against the working tree and the deployed dev copy (test_plugin_api209/209,test_public_visibility33/33,test_public_tool_toggles17/17,test_session_concurrency7/7,test_tool_schemas,test_tools_cli,test_initialize).
Security
Third adversarial review (2026-09-06, everything since 0.9.13: plugin system, field contracts, Grid/Fluid, session lock, the mcp/sdk 0.8.1 eras, category inlining, CP additions). One HIGH and five MEDIUM findings, all fixed here; the SDK upgrade's auth path, the provider proxy, Grid/Fluid row ownership, the CP forms and the public-endpoint invariants were traced and found sound.
- HIGH — anonymous
subscriptions/listencould pin PHP workers. The 2026-07-28 era'ssubscriptions/listenis a protocol method, not a tool call: on the public endpoint it reached neither the public tool gate nor the tool-call rate limiter nor the usage log, and the SDK held the worker (and its MySQL connection) for 30 s per request, so one unauthenticated IP could occupy dozens of workers within the coarse request limit. The public endpoint now serves the handshake era only (PublicMcpServerFactory::withoutModernEra(); the server card advertises 2025-11-25 again), and the OAuth endpoint keeps both eras with the subscription lifetime cut to 2 s (setSubscriptionLifetime), which is honest anyway: no notification bus is configured under PHP-FPM, so a listen stream never carries anything.scripts/test_protocol_eras.phpchecks the public refusal and times the authenticated listen. - MEDIUM — one malformed provider tool definition could 500 the whole endpoint. The SDK builds its registry lazily on the first request and one Tool it cannot construct (an
input_schemawithout a root"type": "object", a handler parameter named$_session) aborted the entire registry: every tool, every member, until a Super Admin unchecked that external tool. NowApi\ToolRegistrarvalidates the shape of an explicitinput_schema(root type object,propertiesan object, everyrequiredentry a property, every property a parameter of the handler, since arguments bind by name) and rejects the definition with a reason like every other key, andMcpServerFactorybuilds each external tool's schema andToolup front, skipping just that tool (logged asexternal_tool_schema_failed) when the SDK refuses it. Covered by five new registrar checks and a containment check inscripts/test_plugin_api.php. - MEDIUM — the session lock was never released explicitly.
Psr7Bridge::emit()ends the request withexit, which skipsfinallyblocks, soSessionLock::release()never ran; MySQL freed the lock when the connection closed, but a persistent database connection (pconnect) would have carried it into the next request on that worker and turned every later request on that session into a 45 s wait and a 503. Both endpoints now release before emitting, andacquire()registers a shutdown release as a safety net. - MEDIUM — relationship refusals were an information oracle. The relationship contract's refusal named the target entry's channel, status, dates and author for any id, including entries in channels the member cannot access, so a write-scoped member could probe arbitrary ids. The message now describes the field's rule and the id only, and names the entry's own properties solely when the member could read that entry anyway.
- MEDIUM — MSM site scoping.
list_channels,get_channeland the category list did not filter bysite_id, so on a Multi-Site Manager install a token bound to one site could read another site's channel layout and every category name of its groups (a tenancy escape, mostly for Super Admins, whose channel check has no site comparison). All three are site-scoped now. - MEDIUM —
create_categorynever setsite_id, so on an MSM install the row landed on site 1 whatever site the group belonged to (unusable throughcreate_entry, which does site-filter). Set explicitly now. - LOW. Lock names carry the site id and the surface (OAuth vs public), so an anonymous request with a leaked session id cannot contend for an OAuth session's lock; a hard ceiling of 500 rows per Grid write and
limit/offset(max 1000) onlist_categoriesbound the work one request can demand;parentIdoncreate_category/update_categorymust be a category of the same group on this site and may not create a cycle; the toggle helper and the Tools page escape their identifiers, and the Developer Log line about a provider that registered nothing is reduced to identifier characters (EE renders that log unescaped); addressing a custom field asfield_id_Nno longer trips the native-column scalar guard.
0.16.0 · September 6, 2026
Grid, File Grid and Fluid fields are readable and writable through MCP with every cell and child under the same field contracts; file fields resolve on read; a lost-response race on concurrent requests to one session is fixed. Verified from a real client (Cursor) on the dev site: docs/dev-client-checklist.md, 16/16.
Added
- File fields resolve to the file on read.
get_entry, and the entry returned bycreate_entry/update_entry, now carry afileobject beside each file field's raw{file:ID:url}value —file_id,file_name,title,upload_location_id,directory_name,mime_type,file_size,url— ornullwhen the field is empty or the file is gone, so a pipeline can verify what it just wrote without aget_fileper field. Resolved through EE's own file-field parser, so every stored form is handled. - Grid fields can be read and written. Until now MCP returned
value: nullfor a Grid and refused to write one.get_channelnow lists a Grid'scolumns(withcol_name, type,required, and the same contract keys a top-level field of that type gets: a file column'sallowed_directory, a text column'smax_length, a select column'soptions…) plusmin_rows/max_rows;get_entryreturnsrows(row_id, values by column name, file columns resolved); andcreate_entry/update_entrytake an array of row objects keyed by column name — the complete set of rows,row_idkeeping an existing row, omitted rows deleted,[]clearing — normalized into the form EE's Grid saves. Every cell is pre-checked through the same per-fieldtype contracts as a top-level field (namedfield.column), a missing required column and the row limits (which EE only checks in the CP's JavaScript) are refused before save, and arow_idthat is not the entry's own is refused. NewServices/GridFieldContract, with the per-type dispatch moved intoServices/FieldContractsso ChannelTools, EntryTools and Grid cells share one path. Verified on a scratch Grid (text / file / select columns), a scratch File Grid (its fixedfilecolumn carries the field's directory restriction, and is reported and enforced as required per row when the field is required, as EE's Grid_lib does), and read against a real gallery Grid. - Fluid fields can be read and written.
get_channellists a Fluid field's childfieldsand fieldgroups, each child with the same contract keys a top-level field of its type gets;get_entryreturnsitemsin display order (single fields with value / file / Grid rows / related ids, field-group instances with their fields); andcreate_entry/update_entrytake an array of items —{"field", "value"}or{"group", "fields"}, the complete set,idkeeping an existing item (a group by the id of any of its rows, untouched fields kept), omitted items deleted with all their data,[]clearing — normalized into the keyed form EE's Fluid saves (ungrouped items wrapped infield_group_id_0as the CP posts them, which is also what makes EE validate them at all). Every child goes through the same contract dispatch, so a wrong-directory file in a Grid column inside a Fluid item is refused asfluid.grid.photobefore save. NewServices/FluidFieldContract; the scratch channel gains a field group and a Fluid field (scripts/setup_scratch_channel.php), and the harness covers create, every refusal, reorder/edit by id, partial group edits, Grid-in-Fluid row edits, deletion clean-up and clearing. Text fields'content_type(integer/numeric/decimal) is now pre-checked with a message naming the field, since inside a Fluid item EE reports it only as a generic form error; a required child left empty is refused by name for the same reason; and EE's two generic messages for what remains (form_validation_error, "There was a problem with one or more Grid fields") are reworded to say what to check.
Fixed
- Two requests on one MCP session at the same time could lose a response. The SDK keeps a session's outgoing response queue inside the session record; two requests handled concurrently each loaded it, queued their own response and saved, and the loser answered HTTP 202 with no body — which the client reports as a request timeout (seen from Cursor on the dev checklist: a re-sent
update_entrytimed out twice). Both endpoints now serialize requests per session with a MySQL named lock (Services/SessionLock, keyed by theMcp-Session-Id; requests without a session are not locked); a request that waits longer than 45 s gets a JSON-RPC 503 "session busy" instead of hanging.scripts/test_session_concurrency.phpreplays the case over HTTP.
0.15.0 · September 6, 2026
Added
- Text fields' max length is exposed and enforced.
field_maxlis only the CP input'smaxlength; EE never checks it on save (EE7 stores text in a TEXT column), so an API write could store a value the CP could not have produced.get_channelnow reportsmax_length(ornull) andcontent_type(all/numeric/integer/decimal) on text fields, and the entry tools refuse a longer value with the limit and the length (Services/TextFieldContract). The content type is reported only: EE's own validation already refuses a non-numeric value, and that error is surfaced with the field name as before. - Option fields list their options, and an unlisted value is refused before save.
select,radio,checkboxes,multi_selectandselectable_buttonsfields now carryoptions([{value, label}], in CP order, resolved the way EE's own fieldtype does: value/label pairs, listed items, or values pre-populated from another channel field, capped at 500 withoptions_truncated) andallow_multipleinget_channel. The entry tools normalize the value (one string for a single-value field, an array or EE's pipe string for a multi-value one) and refuse an unlisted value, or a list on a single-value field, naming the allowed values (Services/OptionFieldContract). EE refused unknown values already, but only after the round trip and without saying what would have been accepted. - Upload destinations say what they accept.
list_file_directoriesnow reportsallowed_types(["all"],["img"]for images only, or EE's mime categories),images_only,max_size_kb,max_widthandmax_height(null= unlimited) per destination, andget_channel'sallowed_directoryobject carries the same keys for the field's directory, so a client learns before uploading whatupload_fileenforces (Services/UploadDestinationInfo, read from the same rowFileUploaderuses). scripts/setup_scratch_channel.phpcreates, idempotently and through EE's own models, a scratch channel with one field of every kind the field-contract harness needs (an unrestricted file field, text with a max length, integer text, select, radio with value/label pairs, checkboxes, multi_select, a multiple relationship with min/max, a category-restricted relationship) plus a closed entry;scripts/test_plugin_api.phpruns the new contracts against it and skips that section with one failure when it is missing.
0.14.1 · September 6, 2026
Fixed
- Public MCP tools now advertise
readOnlyHint: true. All seven public tools are read-only by construction, but none declared it andPublicMcpServerFactory::registerTool()forwarded only the description (the OAuth factory has forwarded annotations since 0.12.0), so the publictools/listcarried no annotations at all. Each#[McpTool]now declares the hint and the factory forwards it, so a client can run public tools without a confirmation prompt.scripts/test_public_tool_toggles.phpbuilds the real public server and checks what its registry advertises.
0.14.0 · September 6, 2026
Field contracts: a write through MCP is now exactly as permissive as the CP publish form, never more. Where a field's settings restrict what its picker offers but EE's own fieldtype validate() never checks it, get_channel reports the rule and create_entry/update_entry refuse a mismatch before anything is saved. Every rule is read from the field's settings, never from a site's field names; EE's own validation still runs afterwards.
Added
- File fields' upload-directory contract is exposed and enforced. EE's file fieldtype can restrict a field to one upload directory (
allowed_directories) and to images only (field_content_type), but only the CP's file picker honours that: EE's ownvalidate()checks just that the file exists and that the member may access its directory, so an API write could put a file from any directory into any file field and nothing refused it, and no client had a way to learn the restriction. Nowget_channelreportsallowed_directory({upload_location_id, name}, ornullwhen the field accepts any directory) andcontent_type(allorimage) on every top-level file field, andcreate_entry/update_entryrefuse a mismatch before anything is saved, with a message naming the field, the directory the file is in, the directory the field requires, and what to do about it (upload there withupload_file, or pick one withlist_files). An image-only field likewise refuses a non-image. Empty values (clearing the field) and values EE cannot resolve to a file are left to EE's own validation exactly as before, which still runs after the pre-check. Both the report and the check come from one newServices/FileFieldContract, so they cannot drift apart. File columns inside Grid fields are not covered (Grid is not writable through MCP yet). Thefieldsparameter description on both entry tools now states the rule, andscripts/test_plugin_api.phpcovers the report and the refused write against the dev site. Companion to Yumpu Embed 1.1.0, whose guidance tells agents to upload the cover into the cover field's directory. - Relationship fields' picker contract is exposed and enforced. A relationship field's settings restrict its CP picker to certain channels, statuses, categories and authors, to non-expired / non-future entries, and to one entry when multiple is off; EE's
validate()checks only required and (when multiple is on) min/max, so an API write could relate a book to another book, to a closed entry, to an entry that does not exist, or three entries on a single-select field.get_channelnow reportsallowed_channels,allowed_statuses,allowed_categories,allowed_authors(eachnullfor any),allow_multiple,min_entries/max_entries,allow_expiredandallow_futureon every top-level relationship field, and the entry tools refuse a child the picker would not have offered, naming the entry, what it is and what the field allows. NewServices/RelationshipFieldContractis the single source for both, likeFileFieldContractfor file fields;scripts/test_plugin_api.phpcovers report, refusals, the fixed save path and clearing against the dev site. - Provider add-ons can describe their fieldtype's value format. A new extension hook,
mcp_server_for_ee_register_field_types, hands providers anApi\FieldTypeRegistrar(apiVersion()1, separate from the frozen tool registrar) whoseaddFieldType()takesaddon,field_type,value_formatand an optionalbuild_withtool name.get_channelthen shows the provider's text as that fieldtype'svalue_format(instead of the generic "string") plus abuild_withkey, so an assistant calls the builder tool rather than hand-writing a value. Validated like tool definitions (add-on installed, fieldtype shipped by that add-on, host-owned typesfile/relationship/grid/file_grid/fluid_fieldreserved, one description per type,build_withprefixed with the add-on name; rejected definitions logged, never thrown) and collected once per request byServices/FieldTypeHintRegistry. Descriptive only: the fieldtype's ownvalidate()still decides what saves. Documented indocs/extending-mcp-tools.md; first consumer is Yumpu Embed'syumpu_embedfield.
Fixed
- Relationship values passed as the documented bare list saved nothing.
[12, 34]reached EE's relationship fieldtype, which reads the ids from adatakey (the CP postsfield_id_N[data][]), as "no relationships": the entry saved, the tool reported success, and the field was silently cleared. The entry tools now normalize every accepted shape (bare list, a single id, numeric strings, EE's own{"data": [...]}) into the form EE saves.[](and{"data": []}) still clears the field.
0.13.2 · September 6, 2026
Fixed
- General page field spacing. The page stacks all its fields inside one fieldset, which EE's CP styles give no vertical rhythm, so each label sat directly on the previous input. A shared
mcp-server-for-ee-stacked-fieldsclass (in_settings_helpers.php, same values the Public MCP limits column already used) now restores the gap between fields.
0.13.1 · September 6, 2026
Added
- External-tool collection is traced in the debug log. Every collection now writes an
external_tools_collectedline (when debug logging is on) with whether EE considered themcp_server_for_ee_register_toolshook active, the subscribing extension classes with their extension-row version, the number of definitions each add-on contributed, and the PHP process id that served the request. Previously only a throwing provider left a trace; a provider that silently registers nothing, or a hook EE does not see, left none. The process id is what distinguishes a stale opcode cache in one of several PHP workers (a provider's tools appear on some refreshes and not others, right after the add-ons were updated in place) from a genuine registration problem. - Developer Log entry when a subscribed provider registers nothing. If the hook fired for at least one extension but produced zero definitions, an entry is written to EE's Developer Log (database-backed, so it needs neither the debug toggle nor a writable cache directory), naming the subscribing classes and pointing at debug logging for the per-definition rejection reasons. Deduplicated by EE, re-alerting at most every five minutes.
Fixed
- External Tools section could vanish on some page loads. 0.13.0 put
container-type: inline-sizeon the section lists (for the narrow-screen header wrapping). In Chrome that list, a direct child of the page's<fieldset>, rendered at 0px height on roughly one load in ten (confirmed in DevTools: the sections were in the DOM but the list had no height), with nothing server-side to log. The exact browser trigger was not isolated; related reports exist ofcontainer-type: inline-sizecollapsing an element's height, and containment is simply not needed here. Container and media queries are gone: the header's expand button now has a fixed flex basis (22rem) so the Enable all / Disable all pair wraps under the title only when the bar cannot fit both, consistently at every width, the same construction the tool rows use. Same change on the Public MCP page.
0.13.0 · September 5, 2026
Changed
- Tools page sections are collapsible. Read, Write, Admin, and each provider add-on are now one section each, collapsed by default with a live "N of M enabled" count in the header, so the page stays short as more add-ons register tools. Every section header — not only the external ones — carries Enable all / Disable all, which flip that section's toggles in place (and open the section) without saving; the Write section notes that Enable all also switches on
delete_entryanddelete_file. Built on the same header-row construction as the Public MCP page's channel rows (an explicit expand button spanning the bar, controls outside it, no<details>/<summary>), with the same sizing values, so the two pages match. - Public MCP page's tool list uses the same collapsible section with its own Enable all / Disable all and count, for consistency. Each channel row on that page also gets Enable all / Disable all, placed inside the expanded panel above the field list and acting on that channel's field toggles (Enable all also switches the channel on); the channel's own header toggle stays as the allowlist gate.
- External tool sections are headed by the provider's friendly add-on name (from its
addon.setup.php), falling back to the shortname. - Narrow screens. Section and channel headers no longer let the title and count slide under the buttons or toggle: the title and count wrap together, the Enable all / Disable all pair drops onto its own line when the bar is narrow (measured on the section list itself via a container query, with a viewport media query as fallback), and the
codename chips in tool and field rows wrap inside their column instead of running under the toggle. On very narrow rows the toggle itself wraps under the text, right-aligned: the info column has a fixed flex basis (16rem), so the wrap happens only when the row cannot fit that plus the toggle, consistently at every width (with anautobasis the content decided, and long descriptions wrapped even on wide rows). The Top IPs and usage-log tables on the Public MCP page, and the clients and audit tables on the Clients & Audit page, now scroll horizontally inside the panel on narrow screens instead of overflowing it, and the usage log's pagination row wraps.
0.12.0 · September 5, 2026
Follow-ups from the Yumpu Embed 1.0.0 integration review, the first real provider on the plugin system.
Added
annotationskey for external tools.ToolRegistrar::addTool()accepts an optionalannotationsarray (any subset ofreadOnlyHint,destructiveHint,idempotentHint,openWorldHint, booleans) and maps it onto the SDK'sToolAnnotationswhen the tool is advertised. A malformed value — not an array, an unknown key, a non-boolean — rejects the whole definition like any other invalid key; an empty array is treated as omitted. The key is additive within API version 1 (still1): a provider that omits it gets the previous behavior and an older host ignores it, so providers can add annotations without a host-version check. TheToolRegistrardocblock and the developer guide now state that policy: only a change a version-1 subscriber could observe bumps the version.- Enable all / Disable all per provider on the Tools page's External tools section. Each add-on's heading carries two buttons that flip every toggle in that add-on's group on or off in place; nothing is saved until Save is pressed, so "enable all but one" is a bulk click, one toggle, and Save. Other providers' groups are untouched.
Changed
- Developer guide (
docs/extending-mcp-tools.md): new "Tool annotations" section with per-hint guidance; "Scopes are gates, not grants" now spells out the member-session bootstrap (ee()->session->userdata('member_id'),ee('Permission'), role ids and permission flags, in-memory only) that Yumpu Embed's delete/sweep handlers rely on; new "Choosing a scope for irreversible operations" section recommendingadminfor account-wide destructive tools and splitting dry-run/delete tools into a read tool and an admin tool; the "Testing your integration" section describes how Yumpu Embed's harness boots EE the same way asscripts/test_plugin_api.php; checklist items for both.DOCUMENTATION.mdupdated to match (Tools page, Extending overview). scripts/test_plugin_api.phpgrew from 37 to 59 checks: annotation validation and normalization, carry-through toexternalHandlers(), and a section that builds the real server throughMcpServerFactoryand asserts what the SDK registry would advertise (external hints present, every built-in read toolreadOnlyHint, no write tool claiming it).
Fixed
- Built-in tool annotations now reach clients. The
readOnlyHintannotations declared on the#[McpTool]attributes were silently dropped:McpServerFactory::registerTool()forwarded only the attribute's description to the SDK, and the SDK uses only whataddTool()receives. The factory now forwards the attribute's annotations too, and every built-in read and admin tool (all nineteen) declaresreadOnlyHint: true, so clients that gate tool calls on annotations can run reads without prompting.
0.11.0 · August 24, 2026
Added
- Per-tool toggles for the Public MCP endpoint. The seven built-in public tools can now be switched off individually by a Super Admin from the Public MCP settings page, backed by a new
public_disabled_toolssettings column (kept fully separate from the OAuth surface'sdisabled_tools— the two surfaces never share a toggle set). A disabled tool is removed from the publictools/list(PublicToolRegistry::enabledHandlers()) and refused at call time inAbstractPublicTool::call()— before the rate limiter, so refused calls never consume the public rate budget — with a newdisabledoutcome recorded in the public usage log. Default: nothing disabled (behavior unchanged on upgrade).
0.10.0 · August 24, 2026
Added
- Plugin system: other add-ons can now register MCP tools. A new
mcp_server_for_ee_register_toolsextension hook hands subscribers aMultimediality\McpServerForEe\Api\ToolRegistrar(API version 1, a frozen contract — providers checkapiVersion()before callingaddTool()). Each definition names the providing add-on, a{addon}_-prefixed tool name, a[Class, method]handler (public, non-static, no-arg-constructible), a declaredread/write/adminscope, and a description; the input schema is derived by reflecting the handler's signature unless one is supplied explicitly. - Opt-in, not auto-enabled. Every registered tool starts disabled. A Super Admin enables individual external tools in a new "External tools" section on the Tools settings page, backed by a new
enabled_external_toolssettings column; until enabled, a tool is neither advertised intools/listnor callable. - Central enforcement wrapper (
Mcp/ExternalToolProxy). Every external tool call — regardless of what the provider's handler does — re-checks the enable-allowlist, requires the declared OAuth scope throughPermissionGate, audit-logs write/admin calls, and sanitizes provider exceptions before they reach the client (logging the real exception to the debug log). A provider may throwMcp\Exception\ToolCallExceptionitself to surface a deliberate, readable error. - Failure containment. A provider whose registration or constructor throws is skipped and logged (
Services/ExternalToolRegistry); it cannot take the MCP endpoint down, and whatever registered cleanly before the failure is kept. - External tools are OAuth-endpoint only — they never appear on the public anonymous MCP endpoint.
0.9.14 · August 23, 2026
Changed
- Added a full manual,
DOCUMENTATION.md, shipped in the release zip alongsideREADME.md,CHANGELOG.md, andLICENSE. It covers every control-panel page, all 28 authenticated tools and the 7 public tools, the OAuth flow, clean-URL setup for Apache/Nginx/LiteSpeed, the entry-creation workflow, the upload ticket flow andonConflict/SVG rules, the public endpoint, the security model, MSM notes, and troubleshooting. README.mdis now a concise landing that links to the manual and the online docs.- Set
docs_urltohttps://multimediality.com/add-ons/mcp-server-for-ee/docsand added the website stubdocs/documentation/index.php(mirrors the license stub; rendersDOCUMENTATION.mdthrough the shared site template).
0.9.13 · August 23, 2026
Pre-launch security review of everything added since 0.7.0 (upload ticket endpoint, per-member tools/list, entry writes, admin analytics, debug logging — ~3,400 new lines). Five adversarial reviewers; all findings below are fixed.
Removed
- Dead code found in the audit:
PermissionGate::requirePermission()/requireSuperAdmin()/memberHas(),AuditLogger::countAll(), and two unused imports inOAuthToken— none were referenced by any tool or CP route.
Security
- SVG stored-XSS on file uploads (HIGH). The upload finisher built EE_Upload's config without
xss_clean, andEE_Upload::initialize()resets that flag to itsfalsedefault — so EE's content XSS clean (the only gate that inspects SVG bytes for embedded<script>and rejects a changed result) never ran. A caller with only upload-scoped write access could store an SVG containing script in an image destination, served from the site origin. Fixed by passing'xss_clean' => xss_check()exactly as EE core's own file manager does. Behaviour note: as in EE's control panel, non-superadmins can no longer upload SVGs to image destinations (EE's XSS clean rewrites any SVG, so it is rejected); superadmins still can. Use PNG/JPG/WebP, or upload SVGs as a superadmin. - Tool-call content leaked into the debug log (HIGH). The MCP SDK logs
Handling request./Handling response./Handling notification.at info level with the full JSON-RPC payload (tool arguments = entry titles, bodies, search queries). SdkLogger passed these through, defeating its own "shape/size, not content" design. These messages (plusSession destroyed., which logged a full session id) are now dropped; the curatedExecuting tool/Tool executed successfullypair already records what a support log needs. - Upload ticket single-use is now race-free (MEDIUM). The ticket was checked before streaming and consumed after, so two concurrent requests with the same ticket could both succeed. It is now claimed atomically up front (one DB row insert wins) before any body is read; a
413 file_too_largereleases the claim so the same ticket can be retried, while any other outcome keeps it consumed. - Log directory guard no longer depends on
.htaccess(MEDIUM)..htaccessdeny is inert on nginx/LiteSpeed. The log file name now includes a per-install unguessable token derived from the site'sencryption_key, so a direct fetch fails even wheresystem/user/cacheis under the document root and.htaccessis ignored. The.htaccess/index.htmlguards remain for Apache. - Related-entry IDs re-gated (LOW).
get_entryreturned relationship child IDs without checking channel access; it now drops targets in channels the caller can't access, matching the public endpoint's re-gating. MemberContext::reset()unconditional (LOW) at the top of the pre-auth bootstrap, so "no/invalid token → empty context" is guaranteed by the code rather than relying on the SAPI never persisting statics (insurance for a future persistent-worker runtime).- Hardening: absolute 512 MB ceiling on any upload regardless of destination/
php.iniconfig;tools/listadvertises scopes literally (matching enforcement, so anmcp:admin-only token isn't shown read/write tools it would be refused); the JSON-RPC log-body capture reads with an explicit 64 KiB cap instead of trustingContent-Length;ticket/bearer/session_idadded to debug-log key redaction; a non-scalar value for a native entry column now returns a clean tool error instead of a 500.
0.9.12 · August 23, 2026
Changed
- The debug log no longer records tool results. "Tool executed successfully" lines now carry
result_type,result_bytes, and the top-levelresult_keysinstead of the fullstructured_content— a log a customer sends for support should not carry their entries, files, or member data along with it, and theget_channelresult alone was the longest line in a real log. Arguments and errors are unchanged.
0.9.11 · August 23, 2026
Changed
- Debug log readability, from reviewing a real downloaded log: the SDK's no-information chatter ("Protocol connected", "Running server…", "Received message", "Queueing response") is no longer written (the per-request summary already records status and timing); the SDK's internal
_session/_requestobjects are stripped from logged tool arguments; an environment banner is written when logging is switched on, and every download is prefixed with a fresh one, so a log is self-describing even when the file predates the banner feature.
0.9.10 · August 23, 2026
Fixed
- 0.9.9's General page fataled on load (
Call to undefined method CP/URL::addQueryStringVariable()) — the Download Debug Log link used a method that does not exist on EE'sCP/URL; it now usessetQueryStringVariable(). Every CP/URL method the add-on calls has been checked against EE core.
0.9.9 · August 23, 2026
Added
- Debug logging now actually captures MCP SDK activity.
Services/SdkLogger.phpis a PSR-3 logger wired into bothMcpServerFactoryandPublicMcpServerFactoryviaServer::builder()->setLogger()— previously no logger was passed, so the SDK's own tool-call and tool-failure diagnostics (CallToolHandler) were silently discarded. Tool executions and their arguments are logged at debug (noisy discovery/registry chatter is filtered out), and every tool failure is logged at error with the real exception class, message, and a short stack trace. Everything still goes throughDebugLog's existing redaction/truncation/opt-in gate. - Per-request summary lines for the OAuth MCP, public MCP, and upload endpoints (
mcp.request,mcp_public.request,mcp_upload.request): HTTP method, status, duration, the authenticated member/client (when present), a session ID prefix, and — for the OAuth MCP endpoint, when the request body is small — the JSON-RPC method name(s) called. Registered viaregister_shutdown_function()so the line fires exactly once regardless of which of the endpoint's several early-return paths ends the request. - Environment banner. The first line of a fresh debug log (on creation, and again right after each 2 MB rotation) records the add-on/EE/PHP version, server software, site hostname, relevant
php.inilimits, and whether the public MCP endpoint is enabled — no more asking a customer for their environment separately. - Download Debug Log / Clear Debug Log buttons on the General settings page, next to the log file path (now also showing file size and last-written time). Download sends the current log (with the rotated archive, if any, prepended) as a single
.logattachment; Clear truncates the current file and deletes the archive. Both are superadmin-gated like every other CP action here.
0.9.8 · August 23, 2026
Changed
- A refused status on
create_entry/update_entrynow lists the statuses the member's role may use ("Status not permitted: open. Your role may use: closed. …") and says where statuses are assigned in the control panel, so a client can fall back without another round-trip.
0.9.7 · August 23, 2026
Added
- Categories on entries.
create_entryandupdate_entrytake an optionalcategoryIdsarray (IDs fromlist_categories). Assignment goes through EE's ownChannelEntrycategory setter, so the channel's per-group required / single selection rules andauto_assign_cat_parentsbehave exactly as in the control panel. IDs that don't exist, or belong to a category group not attached to the channel, are rejected with a message naming the offender. Onupdate_entry, omitting the parameter leaves categories unchanged;[]clears them. Validation errors on categories are reported by group name. get_channellists the channel'scategory_groups(id, name, required, allow_multiple) next torequired_fields, so a client can discover what to assign before creating.
Fixed
scripts/test_tools_cli.phpregisters an EERequestobject on the CLI boot so entry save paths can be exercised (ChannelEntry::onAfterSave() readsee('Request')).
0.9.6 · August 23, 2026
Changed
get_channelnow answers "what do I need to create an entry" directly. Alongside the per-field list it returnsrequired_fields(names),statuses(the channel's assigned statuses),title_required, a note thaturl_titleis generated, and for every field avalue_formathint describing whatcreate_entry/update_entryexpect for that type (file →{file:ID:url}, relationship → array of entry IDs, date → timestamp, Grid/Fluid → not yet supported) plus the field's CP instructions. The tool description tells the model to call it beforecreate_entryand include everyrequired_fieldsentry.
0.9.5 · August 23, 2026
Changed
fieldsoncreate_entry/update_entrymay also set the nativeurl_title,entry_date,expiration_date, andstickycolumns;author_id,channel_id,site_id,statusandtitleremain unreachable through the map by design.
Fixed
create_entry/update_entryvalidation failures surfaced as a generic-32603. The error formatter iterated EE'sValidation\Result::getFailed(), which yieldsRuleobjects, and crashed trying to stringify them — so the client never saw why the entry was rejected. It now usesgetAllErrors(), reports each failing field by itsfield_name(notfield_id_N), points atget_channelfor the required-field list, and explainsinvalid_selectionon file/relationship fields in actionable terms (a stale or nonexistent file ID is the usual cause).url_titleis generated from the title, the way the control panel does it (EE'surlSlug(), made unique within the channel), unless the caller supplies one. Previously everycreate_entrywithout an expliciturl_titlefailed validation.
0.9.4 · August 23, 2026
Fixed
create_entry/update_entryadvertisedfieldsas a JSON array, then rejected arrays. The PHParrayparameter was published to clients astype: "array", so a model following the schema sent[{"seo_title": "…"}, …]and got "Unknown or disallowed field: 0". The parameter now carries an explicit JSON Schema (type: object, keyed byfield_name, with a worked example and the value conventions for file and relationship fields), the tool description points atget_channelfor the field list, and the server additionally accepts the list shapes a client may already have learned ([{"name": …, "value": …}],[{"field_name": value}]) by normalizing them to the map. Anything else gets a message that shows the expected shape.
0.9.3 · August 23, 2026
Changed
- Both MCP endpoints now run the SDK's
ProtocolVersionMiddleware: an unsupportedMcp-Protocol-Versionheader is rejected with400(JSON-RPC-32602), while a missing header is still accepted per the spec's backwards-compatibility rule (treated as 2025-03-26). On the OAuth endpoint it sits outermost so a bad version never reaches the auth layer. Verified in-process: no header → 200,2025-06-18→ 200,2099-01-01→ 400.
0.9.2 · August 23, 2026
Added
- Duplicate-filename handling for
upload_file/upload_file_inline. Both tools take a newonConflictparameter —rename(default, adds a_1-style suffix, same as the old silent-auto-rename behavior),replace(overwrites the existing file in place, keeping the samefile_id, so entries referencing that file don't break), orfail(rejects the call outright, naming the conflictingfile_id).upload_file's ticket response now reportsfile_exists,existing_file({file_id, file_name, file_size, upload_date}ornull), andon_conflictup front, so a client can decide before ever sending bytes; the finished upload from either tool reportsrenamed_from(when a suffix was added) orreplaced: true. replacerequirescan_access_files+can_edit_files(or superadmin) — a newPermissionGate::canEditFiles()— on top of the normal upload permission, checked both when theupload_fileticket is minted and again byFileUploader::fromTempFile()when the upload actually finishes (Actions\McpUploadEndpoint's out-of-band PUT/POST path re-checks everything at delivery time regardless of what the ticket says).failshort-circuits before any bytes are transferred forupload_file(the ticket is never minted) and before decode/staging forupload_file_inline;Services\FileUploader::fromTempFile()also re-checks for a name clash at finish time (not just at mint/inline time) and throws aRuntimeExceptionprefixedfile_exists:that both callers map into the same explanatory message —Actions\McpUploadEndpointturns it into409 {error: "file_exists", existing_file}instead of the generic422 upload_rejected.replacemirrors EE core's ownchannel_form_overwritepath inlegacy/libraries/Filemanager.php::_upload_file()(passing the originalfile_idintosave_file()'s prefs makes itUPDATEthe existingFilemodel instead of inserting a new one) andEE_Upload'soverwriteflag (skips the_1-suffix renaming step). One core gap had to be worked around:EE_Upload::do_upload()always writes through the destination'swriteStream(), and Flysystem'sFilesystem::assertAbsent()throwsFileExistsExceptionif anything is already at that path — regardless of theoverwriteflag, which only controls renaming.FileUploader::fromTempFile()now deletes the existing on-disk file first when replacing (mirroring whatFilemanager::create_thumb()already does for thumbnails), so the write lands cleanly; the thumbnail is regenerated for the same filename as part of the normalsave_file()path either way.- The existing file lookup (
FileUploader::findExistingFile(), exactfile_namematch at the destination root) relies onexp_files.file_name'sutf8mb4_unicode_cicolumn collation (confirmed against the live schema) to mirror EE's own case-insensitive treatment of file names on that table — noLOWER()/COLLATEoverride needed. Services\UploadTicketnow carries theonConflictmode in its encrypted payload; older tickets minted before this change (noconflictkey) default torenameon decode, so nothing already in flight breaks.- README: new "Duplicate filenames (
onConflict)" subsection under "Uploading large files" with a 3-row behavior table. scripts/test_upload_ticket.php: new sub-tests for all threeonConflictmodes over the live ticket/PUT flow (fresh upload,failon a taken name,renameproducing a suffixed name +renamed_from,replacereusing the samefile_idwith updated size — verified via a freshexp_filesSELECT and an on-disk read, not just the endpoint's own JSON), plus a fully in-processreplaceverification againstServices\FileUploader::fromTempFile()directly that has no dependency on the deployed copy of the add-on.replaceis fail-safe: the existing file is set aside (renamed), the replacement is validated and written, and only then is the original discarded; if the replacement is rejected (wrong type, too large, bad dimensions) the original is restored and the row is untouched. Verified in-process: a PHP payload named.pngsent as a replacement is rejected with the original intact; a valid replacement keeps the samefile_idwith updated size/dimensions.
0.9.1 · August 23, 2026
Fixed
- First upload attempt from Cursor failed with nginx
405. The ticket URL was the/?ACT=…form and the instructions saidPUT; nginx's static/index handler refusesPUTon/beforetry_filesreaches PHP (aPOSTretry then succeeded, costing the model a round-trip). The ticket now points at the explicit/index.php?ACT=…URL (hits the PHP location directly on every server) and the response carries ready-to-runcommand(curl,POST --data-binary) andcommand_powershellfields, with the tool description andinstructionstelling the model to run the command as given, not to switch methods, and how to read a non-2xx error. The endpoint still acceptsPUTandPOST.
Verified live on dev (nginx): the generated curl command returns 201 on the first attempt; ticket reuse → 403; text file named .png → 422 with the explanatory message.
0.9.0 · August 23, 2026
Added
- Out-of-band large-file uploads.
upload_fileno longer takes base64 content — it now mints a short-lived (15 minute), single-use upload URL and tells the calling agent the exact shell command to send the file with (curl -T/Invoke-WebRequest -Method Put). The bytes travel straight from disk to the newmcp_uploadaction (Actions\McpUploadEndpoint) and never pass through the model or the MCP transport at all, so a 50-100 MB PDF or video is now practical to upload. The ticket (Services\UploadTicket, AES-GCM viaServices\Crypto, modelled onServices\MfaChallenge) carries the member, destination, and exact file name it was minted for; the endpoint re-checks the member's upload permission for that destination at delivery time (permissions may have changed since minting), streams the request body straight to a temp file (stream_copy_to_stream(), never buffered whole in PHP memory), and finishes the upload through the exact sameEE_Upload/Filemanager::save_file()validation path core's own file manager uses (extracted intoServices\FileUploader::fromTempFile(), with a newServices\StreamingUploadsubclass ofEE_Uploadthat pointsdo_upload()at an existing temp file instead of an in-memory byte string). - This is an intentional breaking rename, not an addition alongside the old tool: existing clients calling
upload_filewith base64base64Contentwill fail (the tool's signature changed todirectoryId, fileNameonly) and need to switch to the new ticket flow, or to the newupload_file_inlinetool below for genuinely tiny files. upload_file_inlineis the small-file tool the oldupload_fileused to be — base64 content, but now hard-capped at 64 KB decoded regardless of the destination's own configuredmax_size(a real Cursor test with a 96 KB file already exceeded the client's own tool-call character limit before it ever reached this add-on). Anything larger is rejected with a message pointing atupload_file.- Both tools are gated identically to the old
upload_file(can_access_files+can_upload_new_files+ assigned upload destination) and neither is disabled by default. Tool count: 28 authenticated tools. - README: new "Uploading large files" subsection explaining the ticket flow, that chat-only clients (Claude Desktop, ChatGPT) cannot use it at all (no shell to run the transfer command from), and the
upload_max_filesize/post_max_sizePHP caveat when a destination has no configuredmax_size. scripts/test_upload_ticket.php: mints a ticket in-process, PUTs a small generated PNG to the deployed dev endpoint, verifies the ticket is single-use (second PUT →403 invalid_ticket) and that a mistyped-extension file is rejected (422 upload_rejected), then deletes exactly the one file row/file it created by exactfile_id/file name.
0.8.2 · August 23, 2026
Fixed
upload_filefailed on every call with a generic-32603from a real client. Three defects, all found by uploading as a restricted member into a real image-only destination: (1)ee()->filemanageris only preloaded for control-panel requests, so the tool fataled on an action request — it is now loaded explicitly; (2) the destination'sallowed_types(img) was handed toEE_Upload, which reads it as a literal extension list and rejected everything as "File not allowed" — the tool now mirrors core's ownFilemanager::_upload_file()(setis_imagefor image-only destinations and letsave_file()enforce the type list); (3)save_file()was not given the upload metadata, so rows recordedfile_size0 and no dimensions/uploader — it now receives the same fields core records.- "File not allowed" rejections now say why (image-only destination, or the permitted types) and that the file content is checked, not just the extension — so a client doesn't keep retrying with a renamed file.
Verified as a restricted member into an image-only destination: PNG accepted with correct size; PHP content named .png rejected; unassigned destination reads as not found; ../ names collapse to a bare filename; oversized payloads rejected before decoding.
0.8.1 · August 23, 2026
Changed
- Backup codes are no longer accepted for MCP authorization. EE Pro issues a single one-time backup code meant for recovering control panel access when the authenticator is lost (using it disables MFA on the account). Spending it to connect an MCP client was the wrong trade, so the MFA step now takes authenticator codes only; the backup-code field is gone and the page says where backup codes belong. (0.7.0's "burn the backup code" behavior is therefore moot.)
Fixed
MFA sign-in appeared to just reload the page. Same root cause as above: the MFA form's hidden
login=1+ credentials re-ran the password step on submit, which re-rendered a fresh MFA page instead of verifying the code. The password-only flow only worked because re-running the login happened to succeed. Authenticator codes are now verified as intended.get_entryreturned every custom field as{}. It passed EE'sFieldFacadeobjects straight to the JSON encoder. It now returns a real map keyed by field name —{field_id, type, label, value}with the stored value (the same shapeupdate_entryaccepts),expiration_dateandsticky, related entry IDs for relationship fields, andcomplex: trueon Grid/Fluid/Relationship fields whose full content lives in their own tables. The tools harness now asserts this.
Security
- Login form fields were carried into the MFA and consent pages as hidden inputs — including the password. The authorize flow re-emits the OAuth request parameters as hidden fields to carry them across the login → MFA → consent POSTs, but it built that set from the raw
$_GET + $_POSTmerge, so after the login POST the next page's HTML containedlogin,username,password, and the previouscsrf_token. The set is now an allowlist of the RFC 6749/7636/8707 authorization parameters only (response_type,client_id,redirect_uri,scope,state,code_challenge,code_challenge_method,resource).
0.8.0 · August 23, 2026
Follow-up to the 0.7.0 security release, driven by testing with a restricted member role on nginx.
Added
get_public_usageadmin tool. Exposes the Public MCP endpoint's usage analytics (call totals, calls per day, tool breakdown, rate-limit activity, top clients/IPs/channels/entries/searches) to a Super Admin over the authenticated endpoint, mirroring the CP's Public Usage panel. Read-only — it never blocks IPs or changes settings — and gated to superadmins only (the log contains visitor IP addresses), so it is hidden fromtools/listfor every other member.- Test harness:
test_tools_cli.phpprintsToolRegistry::allowedHandlers()for the bootstrapped member before the per-tool loop, so per-membertools/listfiltering is directly visible;get_public_usageis exercised.
Changed
tools/listis now per-member. Previously every enabled tool was advertised to every authenticated client regardless of the member's EE role, relying entirely on the per-call check to reject it — a member with no channel access still sawlist_channels/list_entries/etc. intools/list, and a member withoutcan_access_filesstill saw every file tool. The server now bootstraps the member from the bearer token before building the tool list and registers only tools that member's scopes and EE permissions could plausibly use (Mcp\ToolRegistry::allowedHandlers()); everyone else still sees the full enabled set (unauthenticated discovery, or a request that will 401 anyway). Per-call checks are unchanged and remain the actual security boundary — this only changes what is advertised.- File tools now check EE's own three file permissions instead of one.
list_file_directories/list_files/get_filepreviously piggybacked oncan_upload_new_files, so a member who could only browse files in the CP (can_access_files) couldn't use them at all, while a member with only upload rights could still browse destinations they weren't meant to. The gate now matches EE core exactly: browsing requirescan_access_files;upload_fileadditionally requirescan_upload_new_files;delete_fileadditionally requirescan_delete_files. - OAuth pages restyled (sign-in, MFA, consent, complete, error). They now share one layout (
views/_oauth_layout.php) built the way EE builds its own standalone pages: a centred panel using EE's CP design tokens (--ee-*colours, font stack, radius), a primary Allow / secondary Deny, scope list, inline error alerts, the site name in the heading, and automatic light/dark viaprefers-color-scheme— pure CSS, nothing linked from the CP. Forms, field names, and the CSRF/MFA flow are unchanged.
Fixed
list_file_directoriesnow returns real URLs:upload_prefs.urlstores EE config variables unparsed ({base_url}images/avatars/) and the raw value was passed through; it is now resolved with EE'sparse_config_variables(), as the UploadDestination model does.
0.7.0 · August 22, 2026
Pre-launch security and dependency release. A full adversarial review of every surface (OAuth server, authenticated tools, public endpoint, control panel, front-end injection, routing, installer, dependencies) was run against the live EE7 core; everything it found is fixed here. Existing clients keep working, but see the notes on status permissions and file-directory scoping — both can legitimately make a previously-succeeding call fail for members whose EE role was never granted that access in the CP.
Changed
mcp/sdk0.6.0 → 0.7.1 (resolves CVE-2026-53965; also brings the SDK's batch-size cap, body-size cap, and malformedMcp-Session-Idrejection, which apply to both endpoints). No source changes were needed;composer auditis clean.{exp:mcp_server_for_ee:public_url clean="yes"}now falls back to the?ACT=URL on Apache hosts where the public routes have not been installed, instead of advertising a route that would 404. Nginx hosts (no.htaccessto inspect) are unaffected.- Clean URLs now actually work for MCP clients, on both Apache and Nginx. EE runs its CSRF check before the
core_boothook and only exempts?ACT=requests, so a POST to a bare clean path (/mcp,/mcp-public) was rejected with EE's "This form has expired" page before the add-on ever ran; only GET discovery paths worked. The Apache.htaccessblock now issues a307redirect to each action URL ([R=307,L,QSA]) instead of a302— which fetch-based MCP clients replayed as a GET and which dropped the OAuth query string. (An internal rewrite was tried and rejected: EE rebuilds$_GETfromREQUEST_URI, soACTis lost and the CSRF page appears anyway.) The Nginx snippet on Endpoints & Routing is now a complete, generated server-block fragment with the same per-routereturn 307 …lines using this site's real action IDs, plus thelocation / { try_files … /index.php?$query_string; }fallback it previously assumed was present (without it nginx answers every clean path — including the.well-knownOAuth discovery that clients derive from the issuer even when given a?ACT=URL — with its own 404). Re-install the routes (Apache) or re-paste the snippet (Nginx) after upgrading. - Endpoints & Routing: the Install/Remove Public Routes button now sits directly under the Apache
.htaccessblock it acts on, instead of below the Nginx snippet (which it never applied to). - Search queries must be at least 2 characters. The LIKE escaping bug that let a literal
%/_act as a wildcard is fixed. - Test harnesses:
test_tools_cli.phpnow honorsMCP_ADDON_DIRlike the public-visibility harness, loads EE's Session library on a CLI boot, and covers the category/count tools; CLAUDE.md documents the MAMP CLI extension flags (a silentexit 255meant a missingcurl).
Security
- OAuth sign-in now creates a real EE session. The authorize flow's login step mutated the in-memory session via reflection and never called EE's
create_new_session(), so the session id was not regenerated on login (session-fixation window) and the login did not reliably persist to the consent page's Approve/Deny request for a browser with no existing EE session. Login now goes throughcreate_new_session()(new session id, cookie,exp_sessionsrow) and syncs EE Pro'smfa_flagtoskipthe same way Pro's own MFA controllers do, since MFA has already been verified by this flow. The bearer-token path is unchanged and still never writes sessions or cookies. - File tools honor per-directory role assignments.
list_file_directories,list_files,get_file,upload_file, anddelete_fileonly checked the global upload new files permission; a member could list, read, and delete files in upload destinations their role was never assigned in the CP. Every file tool now also requires the destination to be in the member's assigned upload destinations (superadmins exempt); unassigned destinations and their files read as "not found." create_entry/update_entryenforce EE's status permissions. A member limited to e.g.draftcould publishopendirectly. The status must now be both assigned to the channel and to the member's role, mirroring EE's own Channel Entries API; an unpermitted status is rejected with a clear error rather than silently downgraded.- Category and count tools no longer leak across inaccessible channels. Category groups attached only to channels the member cannot access (and their categories) are now "not found," and
count_category_entrieswithout achannelIdcounts only the member's accessible channels instead of the whole site. upload_fileactually works — and is validated. It passed a file array where EE expects a$_FILESkey, so every upload failed with "No File." It now uses EE's own non-HTTP upload path (EE_Upload::raw_upload(), the same one core uses for moblog/XML-RPC), so the destination's allowed types, max size, and content-sniffed MIME type are all enforced by core; file names are reduced to a safe basename; size is capped (destinationmax_sizeor 20 MB) before anything is decoded.- MFA hardening. A used backup code is now burned (clears
backup_mfa_codeand disables MFA, exactly as EE Pro's own backup-code flow does), and the MFA challenge token is single-use once a code verifies (a wrong code still lets the same form retry). - CSRF token is HMAC-signed (double-submit cookie plus a server signature), so a cookie planted by a related domain cannot satisfy the check.
- Scope ceiling.
/authorizenow intersects the requested scopes with what the client registered (RFC 7591scopeis accepted as a space-delimited string or an array; unknown scopes are dropped, and an empty intersection returnsinvalid_scope). - Token exchange requires
redirect_uriwhenever the authorization request carried one (RFC 6749 §4.1.3) — previously omitting it skipped the check. - Registration caps: at most 10
redirect_urisand a 255-characterclient_name, rejected withinvalid_client_metadatainstead of truncated. - No raw exception text to callers.
/oauth/token,/oauth/register, JWKS, and the entry search/count tools now return generic messages; details go to the debug log only. Debug-log redaction also covers barecodeandcsrfkeys. - Empty
encryption_keyfails loudly. Key derivation previously fell back tosite_urlalone (public), making stored signing keys and MFA challenge tokens recoverable. The add-on now refuses to derive a key without anencryption_keyand shows a clear CP alert (and a clean OAuth error page) instead. - Front-end injection is limited to
webpagetemplates. The widget and discovery<link>could be spliced into any template type (RSS/Atom, CSS, JS, XML) whose output happened to contain</body>/</head>— for a full-content feed that is a stored-script vector in third-party readers. Both injectors now also requireee()->TMPL->template_type === 'webpage'. - Anonymous endpoint body cap of 64 KiB (the SDK default is 4 MiB) — oversized requests get a JSON 413 from the transport. Expired MCP sessions are purged 10 minutes after expiry instead of a day.
.htaccesspath sanitizer: control characters insite_url's path can never reach the generated rewrite rules.
0.6.2 · August 18, 2026
Fixed
- The front-end widget never auto-injected on real sites. The injector only treated a page as HTML if the final template string began with
<!DOCTYPE html>/<htmlat byte 0 — but ordinary EE page templates routinely open with a tag ({exp:channel:entries …},{if …}, a comment) and pull the doctype in via an embed, so the check failed on every page and nothing was ever injected. The page test is now simply "does the output contain</body>" (which is also exactly where the widget is injected); non-HTML templates (JSON, XML/RSS, CSS) have no</body>and are still correctly excluded. The discovery<link>injector was unaffected (it already keyed off</head>).
0.6.1 · August 17, 2026
Fixed
- Code blocks on every settings page now use the CP's own theme color scale (
--ee-bg-*) instead of a hardcoded near-black background, so they render correctly in light, dark, and custom CP themes. Previously the hardcoded color was almost identical to the dark-theme panel background and blocks looked unstyled in dark mode. - The Public MCP channel & field allowlist now uses EE's native toggle switches (matching the rest of the page) instead of checkboxes. Each channel is a header bar — clickable anywhere up to the toggle to expand — showing "N of M fields enabled" (updated live as field toggles change, to prompt a second look), with the enable toggle at the right and per-field toggles in a right-hand column like the other pages; turning a channel on expands its field list. Built without
<details>/<summary>so no interactive control is nested in a summary (an accessibility failure): the expand bar is a single real button with a proper accessible name and aria-expanded/aria-controls, and the toggle is a separate control. Sized to match the Quick Start accordions. - The Public MCP page no longer shows the section title twice above the enable toggle.
- Consistent Title Case for headings, section legends, column headers, and buttons across every settings page (e.g. "OAuth Discovery Routes (Apache)", "Channel & Field Allowlist", "Calls Per Day"), matching the original pages; form-field labels and descriptions stay sentence case.
- Public MCP polish: the Visitors & Discovery panel now has a heading like its siblings; widget option fields (position, label, colours) get consistent spacing; the Recent Log filter buttons sit on their own row so they no longer misalign with the inputs or wrap awkwardly at narrow widths; code wells use the theme scale for contrast in every CP theme.
- The Public MCP settings page returned "404: Item does not exist" (
addons/settings/mcp_server_for_ee/public). EE resolves a CP route's URL segment to its class viaStr::studly(), so the segmentpubliclooked for a class namedPublicwhile the class isPublicMcp. The page's route is nowpublic_mcp; the sidebar link, form action, and redirects all follow from it automatically.
0.6.0 · August 17, 2026
Adds an opt-in, read-only, anonymous public MCP endpoint — a separate surface from the OAuth server, off by default, exposing only content an anonymous front-end visitor could already see.
Added
- Public MCP endpoint (
mcp_publicaction, optional clean route/mcp-public): no authentication, no bearer token, no OAuth discovery metadata. Off by default; the master toggle returns503immediately when disabled, before any other work runs. - Visibility rules, enforced by
Services/PublicContentGateon every query: onlystatus = 'open'entries (custom statuses excluded), dated (entry_date <= now) and not expired, in an admin-allowlisted channel, restricted to an admin-allowlisted per-channel set of custom fields. A hidden entry is always reported as "not found," never "forbidden," so its existence is never revealed. No author or member data is ever exposed, and files are out of scope for v1 (afilefield renders as a public URL only when explicitly allowlisted). - Seven public tools —
list_public_channels,list_public_entries,get_public_entry,search_public_entries,list_public_categories,get_public_category,count_public_entries— plus theee://public/siteresource, all served by their own hardcoded registry (Mcp/PublicToolRegistry.php) with no per-tool toggles or scopes. - Fieldtype-based rendering (
Services/PublicFieldRenderer): each allowlisted field is rendered through its own fieldtype'sreplaceTag()API (never through EE's template parser, so no{exp:...}tag ever executes for an anonymous caller), returning both arenderedand (when it differs) arawvalue. Relationship fields return only related entries that independently pass the same visibility gate, so they can't become a side door into hidden content. - Abuse controls: per-IP and site-wide tool-calls-per-minute rate limits enforced centrally by
AbstractPublicTool::call(), plus a coarse request-level flood guard inMcpPublicEndpointfor raw protocol traffic that never reaches a tool call; an admin-managed IP/CIDR blocklist (Services/PublicIpBlocklist);X-Robots-Tag: noindexon every response; and a CORS origin allowlist (empty by default — no CORS headers at all). - Public MCP control panel page (sidebar, between Tools and Quick Start): master toggle with a warning callout; a channel allowlist with a per-channel field allowlist (each field shown with name, label, and type; content-type fields default checked, reference/private-capable types default unchecked); rate-limit and result-cap fields with a live usage panel (current site-wide calls vs. the ceiling, top per-IP consumers, 24h/7d/30d/unique-IP totals); endpoint URLs (
?ACT=and clean) with copy blocks and a ready-made anonymous-client connection snippet; widget options (position, label, primary/accent colors via EE's native color picker); a discovery toggle; logging/privacy settings (retention days, IP-anonymization days, search-text logging toggle); a blocked-IPs textarea; and a "Public Usage" analytics panel (volume over time, top channels/searches/entries, tool and client breakdowns, top IPs with a one-click block/unblock action, a filtered/paginated recent log, and CSV export). - Usage log (
mcp_server_for_ee_public_logtable): one row per public tool call (tool, channel/entry/category, search query, result count, duration, IP, user agent, MCP client name/version, outcome), written centrally so no tool can skip it. Retention defaults to 30 days and IP anonymization (zero the last IPv4 octet / truncate IPv6 to /48) defaults to 7 days, both purged/applied by the existingMaintenanceService; search-query-text logging can be turned off while still recording that a search happened. - Front-end widget: a dismissible "Add this site to your AI agent" button, auto-injected before
</body>on front-end HTML pages via thetemplate_post_parsehook, or placed explicitly with{exp:mcp_server_for_ee:public_widget}(and{exp:mcp_server_for_ee:public_url}for just the URL) — the tag and the auto-inject hook de-duplicate against each other. Dismissal is remembered inlocalStoragefor 30 days; the panel supports focus management, Escape-to-close, andaria-*attributes. - Discovery: a server-card JSON document served identically at
/.well-known/mcp/server-card.json,/.well-known/mcp.json, and/.well-known/mcp-server(Services/PublicDiscovery, following both the SEP-1649 and IETFdraft-serra-mcp-discovery-urifield names where they disagree, with a spec-is-still-draft note), plus a<link rel="mcp-server">tag auto-injected into the front-end<head>when discovery is on. - New settings columns (13
public_*columns onmcp_server_for_ee_settings) and the newmcp_server_for_ee_public_logtable, migrated automatically on update.
Changed
- New leak-proof CLI harness
scripts/test_public_visibility.phpasserts closed/future-dated/expired/non-allowlisted content is never returned byPublicContentGate/PublicEntryQuery/PublicFieldRenderer(32 checks against a live EE database). It seeds and tears down fixture entries with direct SQL rather than theChannelEntrymodel, since model saves fire other installed add-ons' hooks that assume a real CP request and fatal from the CLI.
Fixed
- The clean-URL dispatcher previously mapped
/mcpand/sseto a non-existentActions\Mcpclass, causing a fatal error on any clean-URL hit that wasn't a?ACT=request. - The Quick Start "MCP is currently disabled" notice now links to the General page instead of the vague "above."
0.5.2 · August 17, 2026
Changed
- Quick Start client sections are now listed alphabetically (ChatGPT, Claude, Codex CLI, Cursor, VS Code) with "Other MCP clients" last, and all start collapsed instead of Cursor being expanded by default.
- The "Copy config" button on Quick Start and Endpoints code blocks now sits below the block, right-aligned, instead of overlaying the top-right corner — the overlay covered code on narrow screens and sat off-centre on single-line blocks.
0.5.1 · August 17, 2026
Added
- A "Download plugin icon (PNG)" button in the ChatGPT section generates an icon for ChatGPT's New Plugin dialog (PNG, ≤ 10 KB) from the site's own favicon assets, in order of preference:
apple-touch-icon.png, thenfavicon.png,favicon.svg, andfavicon.ico(PNG-frame ICOs only), falling back to the add-on's bundled icon. Rendering happens entirely in the browser — no image libraries are required on the server — and the icon is automatically stepped down in size until it fits ChatGPT's 10 KB limit.
Changed
- ChatGPT Quick Start steps updated to match the current ChatGPT UI (verified live): Settings → Plugins → Developer mode (toggle under Security and login), then Settings → Plugins → Browse plugins → the + button → New Plugin dialog. Plugins can be set up from the desktop app as well as the web; the copy previously said web-only.
0.5.0 · August 17, 2026
Added
- The Quick Start page now includes a ChatGPT section (web and desktop): enabling Developer mode, adding the MCP server as a custom connector, how OAuth sign-in and dynamic client registration work with ChatGPT's callback URL, and how connectors set up on the web carry over to the ChatGPT desktop apps.
0.4.0 · August 14, 2026
Release-readiness hardening: fixes from a three-track review (security, correctness, packaging) ahead of the first paid release.
Changed
- README rewritten for the release zip: installation without Composer, the six settings pages, the complete 26-tool table (including the count tools), the delete-tools-disabled-by-default behavior, clean-URL setup, and MFA support.
- Added directory-listing
index.htmlguards to all add-on subdirectories.
Fixed
create_category_groupvalidates channel access before saving, so a denied channel can no longer leave behind an orphaned, half-created group.create_category/update_categorynow run model validation and return readable error messages instead of raw database errors.- Clean URLs (
/mcp,/oauth/...) now work when ExpressionEngine is installed in a subdirectory; the.htaccessfallback rule redirects to the correct site path instead of the domain root. - Toggling "Allow every member role" on and saving no longer erases a curated allowed-roles list.
upload_fileno longer leaves a temporary file behind when the upload fails.- Audit logging can no longer block a tool call when parameters contain malformed UTF-8.
- The Clients & Audit page shows its empty-state message when every registered client has been revoked, instead of an empty table.
search_entriessupports anoffsetparameter for paging;list_membersclamps negative offsets.- The
channel_fieldsresource now includes each field'srequiredflag, matchingget_channel. - Removed the unused
use_clean_urlssettings column (dropped automatically on update).
Security
- Members with EE Pro multi-factor authentication enabled are now prompted for their authentication code (or a backup code) during OAuth sign-in. Previously the OAuth login form accepted the password alone, bypassing MFA entirely. Verification uses EE Pro's own MFA service; if MFA is enabled on an account but cannot be verified, sign-in fails closed. MFA attempts are rate limited like password attempts.
- Refresh tokens are now rotated on every use (OAuth 2.1): each refresh exchange atomically revokes the presented refresh token and its access token and issues a new pair. A previously used refresh token — including one presented by the wrong client — is revoked and rejected, so a leaked token can no longer be replayed silently for its full 30-day lifetime.
- The OAuth login, MFA, and consent forms now carry double-submit-cookie CSRF protection, verified on every POST. Previously these forms relied solely on the browser's default SameSite cookie policy, which a site configured with
cookie_samesite = Nonewould have disabled. - Category tools are now site-scoped on multi-site (MSM) installs:
list_categories,get_category,create_category, andupdate_categoryverify the category group belongs to the current site before acting. - The OAuth discovery endpoints (
jwks,metadata,resource_metadata) now respect the add-on's enabled toggle instead of responding while the add-on is disabled.
0.3.3 · August 6, 2026
Changed
- Fresh installs now start with the destructive tools (
delete_entry,delete_file) disabled; a superadmin can enable them from the Tools settings page. Updates to existing installs keep the administrator's current toggle choices unchanged.
0.3.2 · July 15, 2026
Fixed
- Fatal error ("Call to undefined method ControlPanel\Sidebar::process()") on every add-on settings page: EE's route dispatcher calls
process()on the sidebar class even in automatic mode, so the class now defines it as a no-op.
0.3.1 · July 15, 2026
Added
- Superadmins can now disable individual MCP tools globally (Settings), e.g. turning off
delete_entryordelete_filefor every member, including other superadmins. Disabled tools are removed from the server's tool list entirely (tools/listno longer advertises them and they cannot be called), with a defense-in-depth check inPermissionGateas a second layer of enforcement. Stored as a newdisabled_toolscolumn (JSON array of tool names) on the settings table, migrated automatically on update. - The Settings page now uses ExpressionEngine's native sidebar navigation, split into separate pages — General, Endpoints & Routing, Access Control, Tools, Quick Start, and Clients & Audit — each with its own save action, instead of one long scrolling form.
0.2.4 · July 6, 2026
Added
- The Settings → OAuth discovery routes panel now explains nginx setup, alongside the Apache rules, with a copy-paste snippet. Because EE's front controller (
try_files … /index.php) already routes the MCP/OAuth paths to the add-on's dispatcher, nginx generally needs no per-route rules; the snippet only covers the two real nginx caveats — allowing/.well-known/if the server denies dotfiles, and forwardingHTTP_AUTHORIZATIONto PHP. Previously nginx users were shown ApacheRewriteRulesyntax they had to translate by hand. The Apache and nginx guidance are now labeled separately, and the panel clarifies that the?ACT=endpoints work without any rewrites.
Fixed
get_channelreported every custom field asrequired: false, even fields marked required in ExpressionEngine. EE's model castsfield_requiredto a boolean, but the tool compared it against the string'y'(true === 'y'is always false), so AI clients were told all fields were optional. The check now accepts both the model boolean and the raw'y'value.
0.2.3 · July 6, 2026
Security and release-readiness hardening (findings from an internal full-codebase review), plus PHP 8.2 packaging fixes. First build prepared for production testing.
Added
- The installer now checks for the required
opensslandfileinfoPHP extensions and refuses to install with a clear message if either is missing, instead of letting a missing extension surface later as an "undefined function" fatal (for example when OAuth signing keys are first generated).
Fixed
- The bundled dependencies now honor the advertised PHP 8.2 floor. Composer's platform is pinned to
8.2.0, which pulledlcobucci/clockback from 3.5.0 (which required PHP 8.3) to 3.3.1. Previously the release zip'svendor/composer/platform_check.phphard-failed with an HTTP 500 on PHP 8.2 hosts despite the add-on declaring 8.2 support.
Security
- Dynamic client registration now rejects redirect URIs with browser-executable schemes (
javascript:,data:,vbscript:,file:), control characters, or no scheme. Previously such a URI could be stored and later rendered as a clickable link on the authorization completion page, executing script in the site's origin. The completion page also refuses to render any non-http(s) redirect that isn't scheme-safe, as defense in depth. Legitimatehttps, loopbackhttp, and native-app private-use schemes (cursor://,vscode://, etc.) are unaffected. - Access-token validation now asserts the
iss(issuer) andaud(audience) claims and binds each token to thesite_idit was issued for. On a multi-site (MSM) install, where every site shares one signing key, this prevents a token minted for one site from being accepted on another. - The authorize endpoint now fails closed if a client somehow has no registered redirect URIs, instead of skipping redirect validation.
- The debug-log directory is written with an
index.htmland aRequire all denied/Deny from all.htaccess, so logs stay unreadable over the web even on hosts wheresystem/user/cachesits under the document root.
0.2.1 · July 5, 2026
Added
- Opt-in debug logging (Settings → Enable debug logging). Diagnostic events — OAuth token failures, MCP endpoint errors, token validation failures, public route installs, route dispatches — are written to
system/user/cache/mcp_server_for_ee/debug.logso they can be attached to bug reports. Context values under secret-bearing keys (tokens, secrets, keys, authorization codes, signatures) are redacted, long values are truncated, and the log rotates at 2 MB (one archived generation kept, ~4 MB worst case; rotation falls back to truncation if the archive rename fails) - The Quick Start panel now covers Cursor, VS Code (GitHub Copilot), Claude Code & Claude Desktop, Codex CLI, and a generic section for any other MCP client, each in a collapsible accordion with a copyable config snippet
- The MCP initialize response now advertises server icons, a description, and
websiteUrl(MCP spec 2025-11-25 / SEP-973). Site-root favicon assets are preferred (favicon.svg,apple-touch-icon.png,favicon.png,favicon.ico, in that order); if none exist the bundled add-on icon is embedded as a data URI. The advertised server version is now read fromaddon.setup.phpinstead of a hardcoded string
Fixed
- "Install public routes" no longer fails with ".htaccess is not writable" on hosts where PHP's
is_writable()reports false negatives (Windows network shares, some NFS/Samba mounts). The installer now attempts the write and reports the actual filesystem error if it fails. - Dynamic client registration no longer issues a
client_secretto public clients (token_endpoint_auth_method: none), per RFC 7591; a secret is only generated and returned for confidential (client_secret_post) clients
0.2.0 · July 5, 2026
Pre-release security, performance, and cleanup hardening pass.
Changed
list_entrieswithcategoryIdpages inside SQL (JOIN + LIMIT) instead of loading every entry ID in the category into anIN()listlist_entriesorders by entry date/ID so pagination is deterministic- Search and query-filtered count SQL carries a
MAX_EXECUTION_TIME(15000)hint so a pathological scan aborts instead of hanging the request; query-filtered counts are capped at 10,000 list_file_directoriescaches per-destination file counts for 60 seconds- Expired OAuth codes, tokens, sessions, and cache rows are pruned automatically (probabilistic GC on MCP/token requests); access tokens are retained while a live refresh token references them
mcp_server_for_ee_audit_log.created_atis indexed (new installs and via update migration)- Removed unused
league/oauth2-serverdependency (and its transitive packages);lcobucci/clockis now a direct dependency - Pinned
mcp/sdkto exactly 0.6.0 (pre-1.0 SDK; minor bumps may break)
Fixed
- Uninstall now removes the MCP Server For EE rewrite block from
.htaccess(previously left behind pointing at deleted action IDs) - Version strings synchronized across
addon.setup.php, the installer, the extension, and the MCP server info (previously mixed 0.1.18/0.1.19, causing EE to re-run updates) count_category_entrieshonorsstatuswithout achannelId(was silently ignored) and returns a clear error whenqueryis used withoutchannelId(was silently ignored)create_entry/update_entryvalidate before saving and return readable validation errors instead of a 500- MCP endpoint rate-limit fallback default corrected to 120/min to match the settings schema
- Removed duplicate extension registration during updates
Security
- Rate limiting is now enforced: the limiter persists counters in a new
mcp_server_for_ee_rate_limitstable so limits hold across PHP worker processes (the previous in-memory limiter reset on every request and never blocked anything) - OAuth sign-in throttles failed attempts per IP (20 per 5 minutes) and per username (5 per 5 minutes)
oauth/token(60/min per IP) andoauth/register(10/min per IP) endpoints are rate limited- PKCE with S256 is now mandatory for every authorization request (OAuth 2.1); the
plainchallenge method is no longer accepted and authorization codes without a stored challenge are rejected at exchange - Authorization codes are claimed atomically and burn on a failed PKCE check (single use even under race)
- Refresh token exchange verifies the client before consulting the shared response cache
- Entry
fieldsinput is validated against the channel's custom fields; core columns such asauthor_id,channel_id, andsite_idcan no longer be set through the fields map - Signing-key-at-rest encryption upgraded from unauthenticated AES-CBC to AES-256-GCM; legacy payloads are decrypted and transparently re-encrypted on first use
0.1.19 · June 19, 2026
Changed
list_file_directoriesnow includesfile_countper upload destination so agents can discover files without fanning out parallellist_filescallslist_filesparameter order unchanged (directoryId,limit,folderId,offset)- Clearer tool descriptions and validation when
directoryIdis missing or invalid
Fixed
- File tool existence checks use
get_where()instead ofcount_all_results()
0.1.18 · June 19, 2026
Added
list_file_directoriestool — list upload destinations before listing files
Fixed
list_filesnow queries by upload destination id (upload_location_id) instead of the wrongdirectory_idcolumn- File listing uses direct SQL and skips EE File model hydration (avoids slow filesystem/custom-field work)
get_fileuses the same lightweight SQL path
0.1.17 · June 19, 2026
Added
create_category_grouptool — create category groups with optional channel assignmentlist_category_groupsandget_category_grouptools — discover category group IDs and settings
0.1.16 · June 19, 2026
Fixed
- Critical regression in 0.1.15: MCP endpoint passed
McpServerFactorytoStreamableHttpTransportinstead of PSR-17 factories, causing a PHP fatal error and HTML error pages on every request - MCP errors now always return JSON (even when EE debug mode is on) so clients can reconnect instead of failing on
text/htmlresponses
0.1.15 · June 18, 2026
Fixed
- Parallel OAuth refresh requests share a cached token response instead of failing or blocking each other
- Refresh no longer revokes the previous access token immediately (in-flight parallel tool calls keep working)
- Rate limiter avoids EE file cache locks that could stall concurrent MCP requests on local dev stacks
- MCP session writes use a single upsert instead of exists-then-update
- Reduced per-request bootstrap overhead (single server factory, lighter member load)
0.1.14 · June 18, 2026
Fixed
search_entriesSQL error on MySQL (DISTINCT+ORDER BY title) that caused MCP-32603internal errors- Search skips custom field tables that do not exist on a site
- OAuth refresh requests for the same token are serialized to avoid races when clients fire parallel requests
0.1.13 · June 18, 2026
Fixed
- OAuth refresh no longer rotates the refresh token on every use (avoids lockout when MCP clients fail to persist rotated tokens)
- Access token lifetime increased to 4 hours to reduce refresh frequency
- JWT member bootstrap no longer filters members out when site role settings are missing
0.1.12 · June 15, 2026
Fixed
search_entriesnow searches title plus text custom fields (SEO title, description, body, etc.), not title alonecount_channel_entriesandcount_category_entriesquery filter uses the same multi-field search logic
0.1.11 · June 17, 2026
Added
count_category_entriestool — count entries in a category site-wide or within one channel (answers union-category questions without scanning every channel)
Fixed
count_channel_entriescategory filter now uses a directCOUNT(DISTINCT)SQL query instead of EE query-builder joins that could stall
0.1.10 · June 17, 2026
Fixed
get_channelandchannel_fieldsresource used invalidChannelFieldsrelation; now usegetAllCustomFields()
0.1.9 · June 17, 2026
Changed
list_entries,search_entries, andcount_channel_entriesaccept optionalcategoryIdfilter (e.g. count IUOE entries withcategoryId: 12)
Fixed
- Entry categories now load from
category_postsdirectly; EE modelwith('Categories')was not populating onChannelEntry
0.1.8 · June 17, 2026
Changed
- Entry tools (
list_entries,search_entries,get_entry, etc.) now include acategoriesarray on each entry
0.1.7 · June 17, 2026
Fixed
- MCP tools failed with generic
-32603errors because the DI container storedClosurefactories instead of tool instances; tools now register as real objects - OAuth member bootstrap now loads EE roles/permissions into session and uses the member model for permission checks instead of an uninitialized
ee('Permission')facade - Tool permission errors now return readable
ToolCallExceptionmessages instead of opaque internal errors
0.1.6 · June 17, 2026
Changed
- OAuth completion is a simple static page with a continue link and a note to close the tab manually; removed auto-redirect and
window.close()attempts that caused reload loops
0.1.5 · June 17, 2026
Fixed
- OAuth completion uses
303redirect with output-buffer cleanup so the browser leaves?ACT=50immediately when possible - OAuth completion page retries
window.close()after handing off to the MCP client - MCP server now advertises the
toolscapability explicitly and registers all tools with descriptions
0.1.4 · June 17, 2026
Added
count_channel_entriestool — count entries in a channel with optional status/title filters- Clearer
search_entriestool description for finding content by title
Fixed
- MCP tools were not registered because scope checks ran before OAuth context existed at server build time; tools are now always registered and enforced at call time
- OAuth completion page uses the registered client name (e.g. "Cursor") instead of hard-coded branding
0.1.3 · June 17, 2026
Fixed
- OAuth consent: disabling both buttons on submit dropped
approvefrom the POST body, so Allow appeared to do nothing and the consent form reloaded at?ACT=50 - OAuth approve/deny now uses a hidden
oauth_decisionfield and an immediate302redirect to the client callback forhttp(s)URIs - Bare
?ACT=50visits (refresh/back) show a clear error instead of a broken consent form
0.1.2 · June 17, 2026
Fixed
- OAuth dynamic client registration: MySQL
GET_LOCKname exceeded the 64-character limit and caused a database exception during Cursor connect - OAuth register returns JSON errors instead of an ExpressionEngine HTML error page when registration fails
0.1.1 · June 17, 2026
Fixed
- Refresh
.htaccessMCP_SERVER_FOR_EE redirect rules on add-on update so?ACT=IDs stay correct after reinstall WWW-Authenticatealways pointsresource_metadataat the current protected-resource metadata action URL
0.1.0 · June 17, 2026
Added
- ExpressionEngine 7.2+ add-on exposing a Streamable HTTP MCP server
- Integrated OAuth 2.1 authorization server (authorization code + PKCE, refresh tokens, dynamic client registration)
- JWT access tokens with JWKS endpoint and key rotation
- Role-based MCP access control with optional per-member grants
- MCP tools for channels, entries, categories, files, members, roles, and site settings
- MCP resources for channel schemas and member permission summaries
- Control Panel settings UI with endpoint URLs, OAuth client revocation, and audit log
- Release packaging script with Composer vendor bundling
Fixed
- OAuth consent completion page auto-closes the browser popup after Allow/Deny
- OAuth client registration deduplication and race-condition locking for Cursor
channel_fieldsregistered as a resource template (not a static URI with{channelId})- MCP header sanitization for RFC 7230 compatibility with Cursor OAuth
- ExpressionEngine action routing for
_mcpmodule class names