MCP Server For EE – Changelog

Overview · Documentation · License

1.3.3 · October 28, 2026

No code changes.

Changed

  • License terms. Support, Updates & Warranty in LICENSE again 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.md groups 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_key and site_url (Crypto::deriveKey()), so after a database copy to another URL (staging to live, a domain change) or an encryption_key change 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 by scripts/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 id of oauth_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 as OAuth\TokenHash ids, h: plus SHA-256 (Crypto::hashSecret(), as client secrets already were; 62 hex characters, so the existing varchar(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 with SHA2(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's jti, which is no credential on its own, and checked against the signature. Covered by scripts/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.md and CHANGELOG.md directly, 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 (a 413 file_too_large releases 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() asks isSuperAdmin() to decide on the profiler), so on every MCP request — bearer token or upload ticket — it was built for the anonymous visitor, and MemberSessionBootstrap::apply() only updated the session. Every EE check that goes through ee('Permission') therefore answered for a guest. Seen as: a Super Admin's upload_file of 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 the ee://member/permissions resource reported is_superadmin: false and 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 by scripts/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/PublishTabs reads the tab fields EE itself adds to the entry model ({module}__{field}, from each installed module's tab.{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_channel lists them under tabs with each select's options as the publish form offers them to this member; get_entry (and get_entries without fields) reports an entry's values under tabs; create_entry, update_entry and each update_entries item take tabs (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.tabs on a dry run, tabs_changed on the result, since Structure's uri/parent_id are the page's published URL. Covered by scripts/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 .mcpb bundle (npm run build:extension → dist/mcp-server-for-ee-local-<version>.mcpb, validated and packed with Anthropic's mcpb tool) 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-http for 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 to mcp-server-for-ee-local.log in 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 its assets/ folder, and the Quick Start page offers Download Claude Desktop extension for this site: Services/DesktopExtension copies 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's instructions now open by naming the site and its URL, so an assistant connected to several sites can tell them apart. The button needs PHP's zip extension 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/connected is 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-url it 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 .htaccess block gains an internal rewrite for it, and the Nginx snippet's try_files fallback 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.json block (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_metadata action and /.well-known/oauth-protected-resource, plus the WWW-Authenticate challenge's document) named the authorization server by the oauth_metadata action 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's issuer equals 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-server from 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_usage returns 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 failed from 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-server on the site root, and a site with no .htaccess (or an Nginx config without the index.php fallback) 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 a mod_rewrite guard; Remove deletes it again), and in a non-EE .htaccess the 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 .htaccess the 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/list is filtered per member, every mutating call is audited, and any tool can be switched off globally (delete_entry and delete_file ship off).
  • Writes that never exceed the publish form. get_channel reports 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. fieldFilters on list_entries/count_channel_entries/search_entries select entries by a field's state, get_entries reads up to 50 entries in one call, update_entries applies up to 25 updates with a per-item result, returnFields trims write responses, dryRun: true runs every check without writing, and list_entry_revisions/get_entry_revision read 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_file renames a file in place and edits its metadata, list_files finds 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 with list_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-known discovery. Off by default.
  • Plugin API for other add-ons: a versioned mcp_server_for_ee_register_tools hook to contribute tools (scope-gated, audited, opt-in per tool) and a mcp_server_for_ee_register_field_types hook to describe a fieldtype's value and report back after each save (field_reports on 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.
  • urlTitle on create_entry, update_entry and each update_entries item, with the publish form's rules; a derived slug that is already taken is refused (naming the holder) instead of silently suffixed; get_channel reports the whole url_title contract; a changed slug is reported as url_title_changed (and would_change.url_title on a dry run).

Changed

  • Result shapes: list_categories returns count; the search and count tools' query takes 3 to 100 characters on both surfaces; update_entries dry runs return would_update; list_audit_log pages with beforeId/afterId; get_channel marks a Grid or Fluid field after_write: true when 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 audit is clean.

Fixed

  • Files: an uploaded file's file_size is the exact byte count and every file record carries modified_date; update_file refuses 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_category works (it never saved before: the category URL title is now derived from the name and kept unique in the group); search_entries and the query counts search every free-text type EE7 ships (rte, email_address, markdown were 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_entry refuses 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 .htaccess block unasked; a folder copied without vendor/ 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, on Api\FieldTypeRegistrar::addFieldType() (field-type API version stays 1; only optional keys may be added within a version) names a public static afterWrite(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_entry and update_entries call 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 as field_reports keyed by field name beside entry (inside each update_entries item), 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_channel marks such fields after_write: true so a client knows a report will come. Motivation: Yumpu Embed 1.3.0 gave clients a per-family Yumpu rate-limit ledger (yumpu_embed_quota and a quota block on its tools), but an entry save can itself cost one Yumpu call (a value from get_upload_progress costs one GET document on its first save), which the client only saw in its next Yumpu tool result; with this, Yumpu Embed 1.4.0 returns the same quota block on the entry write. Guide: new "Reporting back after a save" subsection, checklist line and version footnote in docs/extending-mcp-tools.md; tool descriptions, server instructions and the tool table mention field_reports. scripts/test_plugin_api.php gains a fake provider covering the registrar validation, the report on update_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 target column on mcp_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_file name their object; create_entry, create_category, create_category_group, upload_file and upload_file_inline are 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's upload_file_complete row 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 with member_name and target_name resolved (the same one-query-per-kind resolvers the CP page uses, now in Services/AuditLogger), count for 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.php adds the column when missing (mirroring the migration) and checks the targets on update_entry/update_entries, that dry runs log nothing, the filtered recent() 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_revisions and get_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 with version_id, author_id, version_date and the total count, plus the channel's versioning_enabled/max_revisions so 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 by field_name like get_entry (Grid/Fluid/relationship values in the raw captured form, marked complex; the CP's categories[...] POST flattened to category_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_data is unserialized with classes disallowed. get_channel now reports versioning_enabled and max_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_id is a site-wide sequence (gaps are normal), and max_revisions rolls 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: true on create_entry, update_entry and update_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, ...} with would_change on update ({from, to} for each touched title, status, field and the category ids, from as get_entry reports the stored value and to as the fieldtype would receive it after the contracts normalised it) or would_create on create (with the derived url_title). update_entries dry-runs every item and reports valid/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.
  • query on list_files. A case-insensitive contains-match on file_name or title (2+ characters) that searches the whole destination, subfolders included (folderId is ignored and reported as null; each row carries its directory_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.php run could leave every external tool disabled on the dev site. The harness swaps enabled_external_tools for its own test tool names and restores the original in a finally block, 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 from tools/list on 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 as update_entry would (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 message update_entry would have given, plus updated and failed counts. 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 by entry_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 bumps edit_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, as get_entries does) and an empty list. returnFields applies to every returned entry, with unknown names listed once at the top level. One audit row per item under update_entries. The cap is 25 rather than get_entries' 50 because a provider fieldtype's save() 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 as update_entry; described on the Tools page, in the server instructions, the tool table and the entries guide. scripts/test_plugin_api.php runs a mixed batch (one applied, one unknown id, one contract refusal), checks order, counts, the stored value, the get_entry shape, returnFields and the hoisted unknown_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

  • fieldFilters on list_entries, count_channel_entries and search_entries. An optional list of up to five {field, op, value?} filters, AND-combined with the existing category/status/query filters: field is a field_name from get_channel, op is empty, not_empty, equals or contains. 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 as yumpu_embed) take all four, against the per-field table or, for fields still on legacy_field_data, the exp_channel_data column — empty is no row, NULL or blank after trim, equals the whole stored string, contains a LIKE (two characters or more); a file field's equals takes a file id (or the stored {file:ID:url}) and matches the id prefix; relationship fields take empty/not_empty/equals with a related entry id, against exp_relationships for the top-level placement; Grid, File Grid and Fluid fields take empty/not_empty against their own row tables (their field column holds only searchable text, which Fluid does not fill, so equals/contains are 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_channel now reports filterable_ops per 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 file equals, a contains under two characters, more than five filters, a malformed item). Server instructions and tool table updated. scripts/test_plugin_api.php covers 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_entries with a filter, [] filtering nothing) and the nine refusals.
  • returnFields on create_entry and update_entry. The same shape as get_entries' fields: omit for every custom field, [] for none (the entry's fields key is then an empty list, and the native properties entry_id, title, status, dates and categories are all that comes back), a list for just those; names the channel does not have come back in unknown_fields at the top level of the result beside entry, where get_entries puts 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_entry responses 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's save() return) rather than what is in them after postSave() and after-save extension hooks ran. Both tools now re-fetch the entry by id after save() 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 following get_entry (one visible difference: a text field with the integer content type comes back as a number, as get_entry always returned it, where the saved model still held the string); the harness checks the response's edit_date and field value against the tables after a write and that it equals get_entry's.
  • get_entries documents fields: []. 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_date was 1 on every entry from get_entry, get_entries, list_entries and search_entries. EE types edit_date as a timestamp column, which the model hydrates as a DateTime, and the formatter cast it with (int), which is 1 for any object; entry_date is typed int and was always right, and the write tools only looked right because they assign time() 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 their edit_date alone. Reproduced on dev before the fix; scripts/test_plugin_api.php now compares get_entry's dates with the channel_titles row.

0.19.0 · September 11, 2026

Added

  • get_entries: up to 50 entries in one call. get_entries(entryIds, fields?) returns each entry in get_entry's shape (every field value, resolved file objects, Grid rows, Fluid items, related ids) in the order the ids were given, so a channel of a few hundred entries is a handful of calls instead of one get_entry per entry serialised through the session lock. The optional fields list (field names from get_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 in skipped with the same single reason get_entry gives, never in entries, so a hidden id is not distinguishable from a missing one; field names no fetched entry's channel has are reported in unknown_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 as get_entry. Advertised to the same members as the other entry read tools; documented in the server instructions and the tool table; covered in scripts/test_tools_cli.php (order, duplicate, skipped id, item identical to get_entry's, the fields filter 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 needs can_edit_files plus 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_name reports 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 no title is 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_id is kept, so entries that reference the file are unaffected. Before renaming, Services/FileReferenceScanner looks for the one reference a rename breaks, the legacy {filedir_N}name form (what file_manager_compatibility_mode sites 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 a usage block (entry and category ids from exp_file_usage) so the caller knows what the file touches, for instance which Yumpu Embed values to rebuild (their stored file_name is a label only; {field:pdf_url} resolves by id). Every file row (list_files, get_file, uploads, update_file) now also carries description, credit, location and usage_count. Advertised in tools/list only to members with can_edit_files; audited as update_file; described on the Tools page and in the server instructions. New scripts/test_file_update.php uploads 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}name reference in the scratch channel (with a near-miss that must not block), read-only scope, unknown id; the usage block against a planted exp_file_usage row; explicit title, empty title reset, metadata-only edits.

0.17.0 · September 6, 2026

Added

  • get_channel inlines each category group's categories. Every entry in category_groups now carries category_count, categories_truncated and categories — 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 existing required/allow_multiple, so a client can choose categoryIds for create_entry/update_entry from the one call it already makes instead of a list_categories round trip per group. A group with more than 200 categories is not inlined: it reports the count with categories_truncated: true, and list_categories (no limit) returns the same rows. One shared Services/CategoryList orders and shapes the rows for get_channel, list_categories and get_category, so list_categories now also returns url_title and CP order. scripts/test_plugin_api.php checks the inlined rows against list_categories and 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 uploads destructiveHint: false; update_entry/update_category destructiveHint: true, idempotentHint: true; delete_entry/delete_file destructiveHint: true), so a client that gates on hints no longer treats a create as destructive. update_entry states 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_entries and search_entries take an optional status filter (and say their fixed order: newest first, and by title). get_entry also accepts channelId + urlTitle. get_public_category's parameter is categoryId like every other category tool (was catId). 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 at list_file_directories; the channel-fields resource says get_channel is authoritative for writes and the channels resource carries the description too; get_channel documents the settable native keys (entry_date, expiration_date, sticky); a status is 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, and get_entry by slug. list_file_directories says directory_id and upload_location_id are the same value.
  • mcp/sdk 0.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 _meta envelope goes to the SDK's stateless leg (no initialize, no Mcp-Session-Id, a throwaway in-memory session, SEP-2243 Mcp-Method/Mcp-Name headers checked against the body, structuredContent on every tool result), while initialize-handshake clients (2025-03-26 … 2025-11-25) keep using DbSessionStore and the per-session lock exactly as before. A client asking initialize for 2026-07-28 is counter-offered 2025-11-25. Two wiring changes were needed: ProtocolVersionMiddleware is 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 advertises 2026-07-28. Behaviour changes inherited from the SDK: an unknown tool on tools/call answers -32602 on every revision (was -32002), and a list-shaped tool result is sent as structuredContent only on 2026-07-28 (older revisions keep the JSON in content). composer audit clean. New scripts/test_protocol_eras.php probes both eras over HTTP against the dev site (handshake initialize/session/400 on a bad version/counter-offer; modern server/discover, tools/list, tools/call with structuredContent, -32020 on a missing Mcp-Method, -32602 on an unknown tool, 401 with WWW-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_api 209/209, test_public_visibility 33/33, test_public_tool_toggles 17/17, test_session_concurrency 7/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/listen could pin PHP workers. The 2026-07-28 era's subscriptions/listen is 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.php checks 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_schema without 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. Now Api\ToolRegistrar validates the shape of an explicit input_schema (root type object, properties an object, every required entry 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, and McpServerFactory builds each external tool's schema and Tool up front, skipping just that tool (logged as external_tool_schema_failed) when the SDK refuses it. Covered by five new registrar checks and a containment check in scripts/test_plugin_api.php.
  • MEDIUM — the session lock was never released explicitly. Psr7Bridge::emit() ends the request with exit, which skips finally blocks, so SessionLock::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, and acquire() 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_channel and the category list did not filter by site_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_category never set site_id, so on an MSM install the row landed on site 1 whatever site the group belonged to (unusable through create_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) on list_categories bound the work one request can demand; parentId on create_category/update_category must 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 as field_id_N no 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 by create_entry/update_entry, now carry a file object 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 — or null when the field is empty or the file is gone, so a pipeline can verify what it just wrote without a get_file per 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: null for a Grid and refused to write one. get_channel now lists a Grid's columns (with col_name, type, required, and the same contract keys a top-level field of that type gets: a file column's allowed_directory, a text column's max_length, a select column's options…) plus min_rows/max_rows; get_entry returns rows (row_id, values by column name, file columns resolved); and create_entry/update_entry take an array of row objects keyed by column name — the complete set of rows, row_id keeping 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 (named field.column), a missing required column and the row limits (which EE only checks in the CP's JavaScript) are refused before save, and a row_id that is not the entry's own is refused. New Services/GridFieldContract, with the per-type dispatch moved into Services/FieldContracts so ChannelTools, EntryTools and Grid cells share one path. Verified on a scratch Grid (text / file / select columns), a scratch File Grid (its fixed file column 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_channel lists a Fluid field's child fields and field groups, each child with the same contract keys a top-level field of its type gets; get_entry returns items in display order (single fields with value / file / Grid rows / related ids, field-group instances with their fields); and create_entry/update_entry take an array of items — {"field", "value"} or {"group", "fields"}, the complete set, id keeping 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 in field_group_id_0 as 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 as fluid.grid.photo before save. New Services/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_entry timed out twice). Both endpoints now serialize requests per session with a MySQL named lock (Services/SessionLock, keyed by the Mcp-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.php replays the case over HTTP.

0.15.0 · September 6, 2026

Added

  • Text fields' max length is exposed and enforced. field_maxl is only the CP input's maxlength; 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_channel now reports max_length (or null) and content_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_select and selectable_buttons fields now carry options ([{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 with options_truncated) and allow_multiple in get_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_directories now reports allowed_types (["all"], ["img"] for images only, or EE's mime categories), images_only, max_size_kb, max_width and max_height (null = unlimited) per destination, and get_channel's allowed_directory object carries the same keys for the field's directory, so a client learns before uploading what upload_file enforces (Services/UploadDestinationInfo, read from the same row FileUploader uses).
  • scripts/setup_scratch_channel.php creates, 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.php runs 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 and PublicMcpServerFactory::registerTool() forwarded only the description (the OAuth factory has forwarded annotations since 0.12.0), so the public tools/list carried 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.php builds 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 own validate() 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. Now get_channel reports allowed_directory ({upload_location_id, name}, or null when the field accepts any directory) and content_type (all or image) on every top-level file field, and create_entry/update_entry refuse 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 with upload_file, or pick one with list_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 new Services/FileFieldContract, so they cannot drift apart. File columns inside Grid fields are not covered (Grid is not writable through MCP yet). The fields parameter description on both entry tools now states the rule, and scripts/test_plugin_api.php covers 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_channel now reports allowed_channels, allowed_statuses, allowed_categories, allowed_authors (each null for any), allow_multiple, min_entries/max_entries, allow_expired and allow_future on 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. New Services/RelationshipFieldContract is the single source for both, like FileFieldContract for file fields; scripts/test_plugin_api.php covers 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 an Api\FieldTypeRegistrar (apiVersion() 1, separate from the frozen tool registrar) whose addFieldType() takes addon, field_type, value_format and an optional build_with tool name. get_channel then shows the provider's text as that fieldtype's value_format (instead of the generic "string") plus a build_with key, 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 types file/relationship/grid/file_grid/fluid_field reserved, one description per type, build_with prefixed with the add-on name; rejected definitions logged, never thrown) and collected once per request by Services/FieldTypeHintRegistry. Descriptive only: the fieldtype's own validate() still decides what saves. Documented in docs/extending-mcp-tools.md; first consumer is Yumpu Embed's yumpu_embed field.

Fixed

  • Relationship values passed as the documented bare list saved nothing. [12, 34] reached EE's relationship fieldtype, which reads the ids from a data key (the CP posts field_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-fields class (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_collected line (when debug logging is on) with whether EE considered the mcp_server_for_ee_register_tools hook 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-size on 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 of container-type: inline-size collapsing 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_entry and delete_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 code name 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 an auto basis 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

  • annotations key for external tools. ToolRegistrar::addTool() accepts an optional annotations array (any subset of readOnlyHint, destructiveHint, idempotentHint, openWorldHint, booleans) and maps it onto the SDK's ToolAnnotations when 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 (still 1): 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. The ToolRegistrar docblock 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 recommending admin for 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 as scripts/test_plugin_api.php; checklist items for both. DOCUMENTATION.md updated to match (Tools page, Extending overview).
  • scripts/test_plugin_api.php grew from 37 to 59 checks: annotation validation and normalization, carry-through to externalHandlers(), and a section that builds the real server through McpServerFactory and asserts what the SDK registry would advertise (external hints present, every built-in read tool readOnlyHint, no write tool claiming it).

Fixed

  • Built-in tool annotations now reach clients. The readOnlyHint annotations declared on the #[McpTool] attributes were silently dropped: McpServerFactory::registerTool() forwarded only the attribute's description to the SDK, and the SDK uses only what addTool() receives. The factory now forwards the attribute's annotations too, and every built-in read and admin tool (all nineteen) declares readOnlyHint: 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_tools settings column (kept fully separate from the OAuth surface's disabled_tools — the two surfaces never share a toggle set). A disabled tool is removed from the public tools/list (PublicToolRegistry::enabledHandlers()) and refused at call time in AbstractPublicTool::call() — before the rate limiter, so refused calls never consume the public rate budget — with a new disabled outcome 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_tools extension hook hands subscribers a Multimediality\McpServerForEe\Api\ToolRegistrar (API version 1, a frozen contract — providers check apiVersion() before calling addTool()). Each definition names the providing add-on, a {addon}_-prefixed tool name, a [Class, method] handler (public, non-static, no-arg-constructible), a declared read/write/admin scope, 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_tools settings column; until enabled, a tool is neither advertised in tools/list nor 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 through PermissionGate, 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 throw Mcp\Exception\ToolCallException itself 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 alongside README.md, CHANGELOG.md, and LICENSE. 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 and onConflict/SVG rules, the public endpoint, the security model, MSM notes, and troubleshooting.
  • README.md is now a concise landing that links to the manual and the online docs.
  • Set docs_url to https://multimediality.com/add-ons/mcp-server-for-ee/docs and added the website stub docs/documentation/index.php (mirrors the license stub; renders DOCUMENTATION.md through 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 in OAuthToken — 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, and EE_Upload::initialize() resets that flag to its false default — 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 (plus Session destroyed., which logged a full session id) are now dropped; the curated Executing tool / Tool executed successfully pair 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_large releases 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). .htaccess deny is inert on nginx/LiteSpeed. The log file name now includes a per-install unguessable token derived from the site's encryption_key, so a direct fetch fails even where system/user/cache is under the document root and .htaccess is ignored. The .htaccess/index.html guards remain for Apache.
  • Related-entry IDs re-gated (LOW). get_entry returned 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.ini config; tools/list advertises scopes literally (matching enforcement, so an mcp: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 trusting Content-Length; ticket/bearer/session_id added 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-level result_keys instead of the full structured_content — a log a customer sends for support should not carry their entries, files, or member data along with it, and the get_channel result 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/_request objects 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's CP/URL; it now uses setQueryStringVariable(). 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.php is a PSR-3 logger wired into both McpServerFactory and PublicMcpServerFactory via Server::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 through DebugLog'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 via register_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.ini limits, 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 .log attachment; 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_entry now 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_entry and update_entry take an optional categoryIds array (IDs from list_categories). Assignment goes through EE's own ChannelEntry category setter, so the channel's per-group required / single selection rules and auto_assign_cat_parents behave 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. On update_entry, omitting the parameter leaves categories unchanged; [] clears them. Validation errors on categories are reported by group name.
  • get_channel lists the channel's category_groups (id, name, required, allow_multiple) next to required_fields, so a client can discover what to assign before creating.

Fixed

  • scripts/test_tools_cli.php registers an EE Request object on the CLI boot so entry save paths can be exercised (ChannelEntry::onAfterSave() reads ee('Request')).

0.9.6 · August 23, 2026

Changed

  • get_channel now answers "what do I need to create an entry" directly. Alongside the per-field list it returns required_fields (names), statuses (the channel's assigned statuses), title_required, a note that url_title is generated, and for every field a value_format hint describing what create_entry/update_entry expect 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 before create_entry and include every required_fields entry.

0.9.5 · August 23, 2026

Changed

  • fields on create_entry/update_entry may also set the native url_title, entry_date, expiration_date, and sticky columns; author_id, channel_id, site_id, status and title remain unreachable through the map by design.

Fixed

  • create_entry/update_entry validation failures surfaced as a generic -32603. The error formatter iterated EE's Validation\Result::getFailed(), which yields Rule objects, and crashed trying to stringify them — so the client never saw why the entry was rejected. It now uses getAllErrors(), reports each failing field by its field_name (not field_id_N), points at get_channel for the required-field list, and explains invalid_selection on file/relationship fields in actionable terms (a stale or nonexistent file ID is the usual cause).
  • url_title is generated from the title, the way the control panel does it (EE's urlSlug(), made unique within the channel), unless the caller supplies one. Previously every create_entry without an explicit url_title failed validation.

0.9.4 · August 23, 2026

Fixed

  • create_entry/update_entry advertised fields as a JSON array, then rejected arrays. The PHP array parameter was published to clients as type: "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 by field_name, with a worked example and the value conventions for file and relationship fields), the tool description points at get_channel for 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 unsupported Mcp-Protocol-Version header is rejected with 400 (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 new onConflict parameter — rename (default, adds a _1-style suffix, same as the old silent-auto-rename behavior), replace (overwrites the existing file in place, keeping the same file_id, so entries referencing that file don't break), or fail (rejects the call outright, naming the conflicting file_id). upload_file's ticket response now reports file_exists, existing_file ({file_id, file_name, file_size, upload_date} or null), and on_conflict up front, so a client can decide before ever sending bytes; the finished upload from either tool reports renamed_from (when a suffix was added) or replaced: true.
  • replace requires can_access_files + can_edit_files (or superadmin) — a new PermissionGate::canEditFiles() — on top of the normal upload permission, checked both when the upload_file ticket is minted and again by FileUploader::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).
  • fail short-circuits before any bytes are transferred for upload_file (the ticket is never minted) and before decode/staging for upload_file_inline; Services\FileUploader::fromTempFile() also re-checks for a name clash at finish time (not just at mint/inline time) and throws a RuntimeException prefixed file_exists: that both callers map into the same explanatory message — Actions\McpUploadEndpoint turns it into 409 {error: "file_exists", existing_file} instead of the generic 422 upload_rejected.
  • replace mirrors EE core's own channel_form_overwrite path in legacy/libraries/Filemanager.php::_upload_file() (passing the original file_id into save_file()'s prefs makes it UPDATE the existing File model instead of inserting a new one) and EE_Upload's overwrite flag (skips the _1-suffix renaming step). One core gap had to be worked around: EE_Upload::do_upload() always writes through the destination's writeStream(), and Flysystem's Filesystem::assertAbsent() throws FileExistsException if anything is already at that path — regardless of the overwrite flag, which only controls renaming. FileUploader::fromTempFile() now deletes the existing on-disk file first when replacing (mirroring what Filemanager::create_thumb() already does for thumbnails), so the write lands cleanly; the thumbnail is regenerated for the same filename as part of the normal save_file() path either way.
  • The existing file lookup (FileUploader::findExistingFile(), exact file_name match at the destination root) relies on exp_files.file_name's utf8mb4_unicode_ci column collation (confirmed against the live schema) to mirror EE's own case-insensitive treatment of file names on that table — no LOWER()/COLLATE override needed.
  • Services\UploadTicket now carries the onConflict mode in its encrypted payload; older tickets minted before this change (no conflict key) default to rename on 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 three onConflict modes over the live ticket/PUT flow (fresh upload, fail on a taken name, rename producing a suffixed name + renamed_from, replace reusing the same file_id with updated size — verified via a fresh exp_files SELECT and an on-disk read, not just the endpoint's own JSON), plus a fully in-process replace verification against Services\FileUploader::fromTempFile() directly that has no dependency on the deployed copy of the add-on.
  • replace is 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 .png sent as a replacement is rejected with the original intact; a valid replacement keeps the same file_id with 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 said PUT; nginx's static/index handler refuses PUT on / before try_files reaches PHP (a POST retry 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-run command (curl, POST --data-binary) and command_powershell fields, with the tool description and instructions telling the model to run the command as given, not to switch methods, and how to read a non-2xx error. The endpoint still accepts PUT and POST.

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_file no 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 new mcp_upload action (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 via Services\Crypto, modelled on Services\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 same EE_Upload/Filemanager::save_file() validation path core's own file manager uses (extracted into Services\FileUploader::fromTempFile(), with a new Services\StreamingUpload subclass of EE_Upload that points do_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_file with base64 base64Content will fail (the tool's signature changed to directoryId, fileName only) and need to switch to the new ticket flow, or to the new upload_file_inline tool below for genuinely tiny files.
  • upload_file_inline is the small-file tool the old upload_file used to be — base64 content, but now hard-capped at 64 KB decoded regardless of the destination's own configured max_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 at upload_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_size PHP caveat when a destination has no configured max_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 exact file_id/file name.

0.8.2 · August 23, 2026

Fixed

  • upload_file failed on every call with a generic -32603 from a real client. Three defects, all found by uploading as a restricted member into a real image-only destination: (1) ee()->filemanager is only preloaded for control-panel requests, so the tool fataled on an action request — it is now loaded explicitly; (2) the destination's allowed_types (img) was handed to EE_Upload, which reads it as a literal extension list and rejected everything as "File not allowed" — the tool now mirrors core's own Filemanager::_upload_file() (set is_image for image-only destinations and let save_file() enforce the type list); (3) save_file() was not given the upload metadata, so rows recorded file_size 0 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_entry returned every custom field as {}. It passed EE's FieldFacade objects 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 shape update_entry accepts), expiration_date and sticky, related entry IDs for relationship fields, and complex: true on 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 + $_POST merge, so after the login POST the next page's HTML contained login, username, password, and the previous csrf_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_usage admin 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 from tools/list for every other member.
  • Test harness: test_tools_cli.php prints ToolRegistry::allowedHandlers() for the bootstrapped member before the per-tool loop, so per-member tools/list filtering is directly visible; get_public_usage is exercised.

Changed

  • tools/list is 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 saw list_channels/list_entries/etc. in tools/list, and a member without can_access_files still 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_file previously piggybacked on can_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 requires can_access_files; upload_file additionally requires can_upload_new_files; delete_file additionally requires can_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 via prefers-color-scheme — pure CSS, nothing linked from the CP. Forms, field names, and the CSRF/MFA flow are unchanged.

Fixed

  • list_file_directories now returns real URLs: upload_prefs.url stores EE config variables unparsed ({base_url}images/avatars/) and the raw value was passed through; it is now resolved with EE's parse_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/sdk 0.6.0 → 0.7.1 (resolves CVE-2026-53965; also brings the SDK's batch-size cap, body-size cap, and malformed Mcp-Session-Id rejection, which apply to both endpoints). No source changes were needed; composer audit is 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 .htaccess to inspect) are unaffected.
  • Clean URLs now actually work for MCP clients, on both Apache and Nginx. EE runs its CSRF check before the core_boot hook 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 .htaccess block now issues a 307 redirect to each action URL ([R=307,L,QSA]) instead of a 302 — 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 $_GET from REQUEST_URI, so ACT is 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-route return 307 … lines using this site's real action IDs, plus the location / { try_files … /index.php?$query_string; } fallback it previously assumed was present (without it nginx answers every clean path — including the .well-known OAuth 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 .htaccess block 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.php now honors MCP_ADDON_DIR like 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 silent exit 255 meant a missing curl).

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 through create_new_session() (new session id, cookie, exp_sessions row) and syncs EE Pro's mfa_flag to skip the 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, and delete_file only 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_entry enforce EE's status permissions. A member limited to e.g. draft could publish open directly. 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_entries without a channelId counts only the member's accessible channels instead of the whole site.
  • upload_file actually works — and is validated. It passed a file array where EE expects a $_FILES key, 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 (destination max_size or 20 MB) before anything is decoded.
  • MFA hardening. A used backup code is now burned (clears backup_mfa_code and 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. /authorize now intersects the requested scopes with what the client registered (RFC 7591 scope is accepted as a space-delimited string or an array; unknown scopes are dropped, and an empty intersection returns invalid_scope).
  • Token exchange requires redirect_uri whenever the authorization request carried one (RFC 6749 §4.1.3) — previously omitting it skipped the check.
  • Registration caps: at most 10 redirect_uris and a 255-character client_name, rejected with invalid_client_metadata instead 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 bare code and csrf keys.
  • Empty encryption_key fails loudly. Key derivation previously fell back to site_url alone (public), making stored signing keys and MFA challenge tokens recoverable. The add-on now refuses to derive a key without an encryption_key and shows a clear CP alert (and a clean OAuth error page) instead.
  • Front-end injection is limited to webpage templates. 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 require ee()->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.
  • .htaccess path sanitizer: control characters in site_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>/<html at 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 via Str::studly(), so the segment public looked for a class named Public while the class is PublicMcp. The page's route is now public_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_public action, optional clean route /mcp-public): no authentication, no bearer token, no OAuth discovery metadata. Off by default; the master toggle returns 503 immediately when disabled, before any other work runs.
  • Visibility rules, enforced by Services/PublicContentGate on every query: only status = '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 (a file field 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 the ee://public/site resource, 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's replaceTag() API (never through EE's template parser, so no {exp:...} tag ever executes for an anonymous caller), returning both a rendered and (when it differs) a raw value. 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 in McpPublicEndpoint for raw protocol traffic that never reaches a tool call; an admin-managed IP/CIDR blocklist (Services/PublicIpBlocklist); X-Robots-Tag: noindex on 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_log table): 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 existing MaintenanceService; 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 the template_post_parse hook, 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 in localStorage for 30 days; the panel supports focus management, Escape-to-close, and aria-* 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 IETF draft-serra-mcp-discovery-uri field 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 on mcp_server_for_ee_settings) and the new mcp_server_for_ee_public_log table, migrated automatically on update.

Changed

  • New leak-proof CLI harness scripts/test_public_visibility.php asserts closed/future-dated/expired/non-allowlisted content is never returned by PublicContentGate/PublicEntryQuery/PublicFieldRenderer (32 checks against a live EE database). It seeds and tears down fixture entries with direct SQL rather than the ChannelEntry model, 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 /mcp and /sse to a non-existent Actions\Mcp class, 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, then favicon.png, favicon.svg, and favicon.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.html guards to all add-on subdirectories.

Fixed

  • create_category_group validates channel access before saving, so a denied channel can no longer leave behind an orphaned, half-created group.
  • create_category / update_category now 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 .htaccess fallback 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_file no 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_entries supports an offset parameter for paging; list_members clamps negative offsets.
  • The channel_fields resource now includes each field's required flag, matching get_channel.
  • Removed the unused use_clean_urls settings 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 = None would have disabled.
  • Category tools are now site-scoped on multi-site (MSM) installs: list_categories, get_category, create_category, and update_category verify 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_entry or delete_file for every member, including other superadmins. Disabled tools are removed from the server's tool list entirely (tools/list no longer advertises them and they cannot be called), with a defense-in-depth check in PermissionGate as a second layer of enforcement. Stored as a new disabled_tools column (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 forwarding HTTP_AUTHORIZATION to PHP. Previously nginx users were shown Apache RewriteRule syntax 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_channel reported every custom field as required: false, even fields marked required in ExpressionEngine. EE's model casts field_required to 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 openssl and fileinfo PHP 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 pulled lcobucci/clock back from 3.5.0 (which required PHP 8.3) to 3.3.1. Previously the release zip's vendor/composer/platform_check.php hard-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. Legitimate https, loopback http, and native-app private-use schemes (cursor://, vscode://, etc.) are unaffected.
  • Access-token validation now asserts the iss (issuer) and aud (audience) claims and binds each token to the site_id it 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.html and a Require all denied / Deny from all .htaccess, so logs stay unreadable over the web even on hosts where system/user/cache sits 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.log so 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 from addon.setup.php instead 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_secret to 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_entries with categoryId pages inside SQL (JOIN + LIMIT) instead of loading every entry ID in the category into an IN() list
  • list_entries orders 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_directories caches 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_at is indexed (new installs and via update migration)
  • Removed unused league/oauth2-server dependency (and its transitive packages); lcobucci/clock is now a direct dependency
  • Pinned mcp/sdk to 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_entries honors status without a channelId (was silently ignored) and returns a clear error when query is used without channelId (was silently ignored)
  • create_entry / update_entry validate 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_limits table 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) and oauth/register (10/min per IP) endpoints are rate limited
  • PKCE with S256 is now mandatory for every authorization request (OAuth 2.1); the plain challenge 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 fields input is validated against the channel's custom fields; core columns such as author_id, channel_id, and site_id can 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_directories now includes file_count per upload destination so agents can discover files without fanning out parallel list_files calls
  • list_files parameter order unchanged (directoryId, limit, folderId, offset)
  • Clearer tool descriptions and validation when directoryId is missing or invalid

Fixed

  • File tool existence checks use get_where() instead of count_all_results()

0.1.18 · June 19, 2026

Added

  • list_file_directories tool — list upload destinations before listing files

Fixed

  • list_files now queries by upload destination id (upload_location_id) instead of the wrong directory_id column
  • File listing uses direct SQL and skips EE File model hydration (avoids slow filesystem/custom-field work)
  • get_file uses the same lightweight SQL path

0.1.17 · June 19, 2026

Added

  • create_category_group tool — create category groups with optional channel assignment
  • list_category_groups and get_category_group tools — discover category group IDs and settings

0.1.16 · June 19, 2026

Fixed

  • Critical regression in 0.1.15: MCP endpoint passed McpServerFactory to StreamableHttpTransport instead 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/html responses

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_entries SQL error on MySQL (DISTINCT + ORDER BY title) that caused MCP -32603 internal 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_entries now searches title plus text custom fields (SEO title, description, body, etc.), not title alone
  • count_channel_entries and count_category_entries query filter uses the same multi-field search logic

0.1.11 · June 17, 2026

Added

  • count_category_entries tool — count entries in a category site-wide or within one channel (answers union-category questions without scanning every channel)

Fixed

  • count_channel_entries category filter now uses a direct COUNT(DISTINCT) SQL query instead of EE query-builder joins that could stall

0.1.10 · June 17, 2026

Fixed

  • get_channel and channel_fields resource used invalid ChannelFields relation; now use getAllCustomFields()

0.1.9 · June 17, 2026

Changed

  • list_entries, search_entries, and count_channel_entries accept optional categoryId filter (e.g. count IUOE entries with categoryId: 12)

Fixed

  • Entry categories now load from category_posts directly; EE model with('Categories') was not populating on ChannelEntry

0.1.8 · June 17, 2026

Changed

  • Entry tools (list_entries, search_entries, get_entry, etc.) now include a categories array on each entry

0.1.7 · June 17, 2026

Fixed

  • MCP tools failed with generic -32603 errors because the DI container stored Closure factories 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 ToolCallException messages 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 303 redirect with output-buffer cleanup so the browser leaves ?ACT=50 immediately when possible
  • OAuth completion page retries window.close() after handing off to the MCP client
  • MCP server now advertises the tools capability explicitly and registers all tools with descriptions

0.1.4 · June 17, 2026

Added

  • count_channel_entries tool — count entries in a channel with optional status/title filters
  • Clearer search_entries tool 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 approve from 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_decision field and an immediate 302 redirect to the client callback for http(s) URIs
  • Bare ?ACT=50 visits (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_LOCK name 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 .htaccess MCP_SERVER_FOR_EE redirect rules on add-on update so ?ACT= IDs stay correct after reinstall
  • WWW-Authenticate always points resource_metadata at 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_fields registered as a resource template (not a static URI with {channelId})
  • MCP header sanitization for RFC 7230 compatibility with Cursor OAuth
  • ExpressionEngine action routing for _mcp module class names