feat(plugin-mcp)!: mcp plugin refactor, add stdio mcp#16726
Open
AlessioGr wants to merge 50 commits into
Open
Conversation
Snapshot the existing plugin so the rewrite can land in a fresh plugin-mcp folder while keeping the old version available locally for diffing.
Contributor
📦 esbuild Bundle Analysis for payloadThis analysis was generated by esbuild-bundle-analyzer. 🤖
Largest pathsThese visualization shows top 20 largest paths in the bundle.Meta file: packages/next/meta_index.json, Out file: esbuild/index.js
Meta file: packages/payload/meta_index.json, Out file: esbuild/index.js
Meta file: packages/payload/meta_shared.json, Out file: esbuild/exports/shared.js
Meta file: packages/richtext-lexical/meta_client.json, Out file: esbuild/exports/client_optimized/index.js
Meta file: packages/ui/meta_client.json, Out file: esbuild/exports/client_optimized/index.js
Meta file: packages/ui/meta_shared.json, Out file: esbuild/exports/shared_optimized/index.js
DetailsNext to the size is how much the size has increased or decreased compared with the base branch of this PR.
|
AlessioGr
commented
May 23, 2026
| @@ -0,0 +1,344 @@ | |||
| 'use client' | |||
Member
Author
There was a problem hiding this comment.
Basic custom field. This is not the final design. Since access is now a json field instead of a group field, we just need something to replace the old ui.
Contributor
|
Great changes @AlessioGr! I guess adding the missing parameters to the find tool is out of scope of this PR? #15880 |
Member
Author
Yep! The remaining planned improvements + all open MCP issues like this one are on my radar and will be done as individual PRs |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #16339
This PR refactors
@payloadcms/plugin-mcp. The public API and main ideas stay mostly the same, but the config shape, access model, and internals all changed. The new architecture lands in one PR; follow-up improvements are planned separately.Breaking changes
The plugin config API changed across the board, so most setups need updating.
Collections and globals are opt-out, not opt-in
Installing the plugin is now enough to get a working MCP server: every collection and global is exposed by default with the standard CRUD tools (find, create, update, delete). You opt OUT of what you don't want instead of opting IN to each piece.
After upgrading, collections you never listed before are reachable over MCP. Review what's exposed and disable anything that shouldn't be.
mcpPlugin({ collections: { - posts: { enabled: true }, - users: { enabled: { find: true } }, + // posts is exposed automatically, no entry needed + users: { tools: { create: false, update: false, delete: false } }, // find only }, })A consistent shape for tools, prompts and resources
Registering an MCP capability used to mean juggling three different API shapes:
collections.<slug>.enabled.<op>: built-in CRUD. Not keyed undertools.mcp.tools[]: custom tools. Keyed undertools, but as an array.experimental.tools.<kind>: auth and codegen. Keyed undertoolsagain, but as a nested map per kind.Now there's one shape everywhere: a
tools/prompts/resourcesmap, applied either nested under a collection or global (config.collections[slug].tools,config.globals[slug].tools) or at the top level (config.tools).Before.
After. One
name: valuemap at two scopes. The same map holds built-in tools, overrides, opt-in auth tools, and custom tools side by side:Fully typed tool handlers
The schema you pass as
inputflows into the handler's argument:Same inference for all four builders (
defineTool,defineCollectionTool,defineGlobalTool,definePrompt). Inputs accept any Standard Schema (Zod, Valibot, etc.) or a raw JSON Schema. The oldparameters: ZodRawShapeis gone, and handlers now take named arguments instead of positional ones.Nested instead of hoisted
Two places in the old plugin packed unrelated concepts into the same top-level namespace, which made name conflicts easy. Both are now nested by kind.
Tool inputs. The built-in
createandupdatetools used to place document fields right next to option fields likedepth,draft,localeandselect. A field literally calleddraftordepthwould collide with the option of the same name. Document fields now live under their owndatakey:_status,id,createdAtandupdatedAtare also stripped fromdatasince Payload manages them.API key access document. The old document hung every collection, global, tool, prompt and resource off the root, with custom items wedged under awkward
payload-mcp-tool/payload-mcp-resource/payload-mcp-promptkeys. Everything now goes into oneaccessJSON field, nested by kind:Auth tools are scoped and opt-in
The auth tools (
login,verify,forgotPassword,resetPassword,unlock,auth) used to be flat, development-only tools that took acollectionargument. They're now per-collection and bound to it:A leaner public surface
A handful of options got removed:
mcp.handlerOptionsis gone.verboseLogssurvived asmcp.verboseLogs;onEvent,maxDuration,disableSse,redisUrlandbasePathwere dropped (the new server has no SSE/Redis path).experimental.tools(the tools that scaffolded and edited collection, job and config files on disk) was removed entirely, with no replacement.GET /api/mcproute was dropped; onlyPOSTremains.API keys must be recreated
The
payload-mcp-api-keyscollection keeps its slug, but its fields changed completely. Access used to be defined via multiple group and checkbox fields. Now, the whole tree now lives in a singleaccessJSON field with a custom checkbox UI in the admin panel.The old layout meant that on a postgres/drizzle db, adding or removing a collection, tool, prompt or resource changed the schema, requiring a migration. With the access tree kept as JSON, the table schema stays stable and no migrations are required.
To migrate, delete your existing API keys after upgrading and create fresh ones.
Dependencies
Bundling
plugin-mcp/src/index.tswith esbuild (externalizingpayload,@payloadcms/ui,react):src/index.ts(minified)origin/mainfeat/mcp-localAbout a 9x smaller bundle, ~4.3 MB shaved off. The biggest single contributor was the runtime use of
convertCollectionSchemaToZod, which calledimport * as ts from 'typescript'. We were shipping the entire TypeScript package so we could transpile a generated source-code string at runtime and new Function()-eval it into a zod schema - now,z.fromJSONSchema()from zod v4 can now do this cleanly in a single call.The rest came from swapping the SDK.
@modelcontextprotocol/sdk@1.xwas a bloated kitchen sink: Express 5, Hono,@hono/node-server,cors,express-rate-limit,jose,ajv+ajv-formats,pkce-challenge. 29 MB unpacked with dependencies, plusmcp-handler(which added Redis).@modelcontextprotocol/server@2.0.0-alpha.2is one runtime dependency (zod) and one optional peer (@cfworker/json-schema). 6 MB unpacked with dependencies.In the next 2.0 alpha release, we'll be able to get rid of @cfworker/json-schema to further cut bundle size.
@modelcontextprotocol/sdk@1.:
@modelcontextprotocol/server@2 alpha:
What else is new
npx payload-mcp. Nopluginsarray entry, HTTP server, or API key required.The internal refactor
The biggest internal wins are that the public API is now used internally, an improved, simpler folder structure, and the up-front sanitization into a flat
itemsarray that's much easier to work with.That collapses several parallel flows that used to coexist: a 545-line
getMcpHandler.tsthat hand-registered every kind, atools/resource/*family that rannew Function('z', ...)per request, three filesystem-codegen families (tools/collection/*,tools/config/*,tools/job/*, now gone), and inline auth checks scattered across tool files. Now the plugin runs through:sanitizeMCPConfigwalks the user config and thebuiltinTools.tsregistry, producing a flatitems: MCPItem[].mcpEndpointcallsgetAuthorizedMCPto resolve the API key (or dev-mode session) and filteritemsagainst the access document, thenbuildMcpServeriterates and registers each on mcp item. Auth lives in one single file (endpoint/access.ts).stdio.tssynthesizes a full-accessAuthorizedMCPand connects aStdioServerTransport.Per-request work, old vs new
The single biggest reduction is how tools get registered: the old plugin had a separate iteration and code branch for every kind of thing it could expose; the new one sanitizes all of them into one flat array at boot and walks it once.
Old: everything happens per request. Each config source resolves to its own bespoke tool file, and
mcpAccessSettings(a flat per-slug permissions object) is consulted at every register site to decide whether to expose that specific tool.flowchart TB subgraph perReqOld["Per request"] req[POST /api/mcp] req --> auth["getDefaultMcpAccessSettings<br/>Bearer + API key lookup"] auth --> mas["mcpAccessSettings<br/>(flat per-slug permissions)"] mas --> setup["mcp-handler invokes setup callback"] setup --> col["collections.<slug>.enabled.<op>"] setup --> glb["globals.<slug>.enabled.<op>"] setup --> exp["experimental.tools.{auth, collections, config, jobs}"] setup --> mct["mcp.tools[]"] setup --> mcpP["mcp.prompts[]"] setup --> mcr["mcp.resources[]"] col --> tr["tools/resource/{create,find,update,delete}.ts<br/>+ convertCollectionSchemaToZod (eval)"] glb --> tg["tools/global/{find,update}.ts"] exp --> texp["tools/auth/* · tools/collection/* (codegen)<br/>tools/config/* (codegen) · tools/job/* (codegen)"] mct --> uh1["user handler"] mcpP --> uh2["user handler"] mcr --> uh3["user handler"] mas -. access check .-> tr mas -. access check .-> tg mas -. access check .-> texp mas -. access check .-> uh1 mas -. access check .-> uh2 mas -. access check .-> uh3 tr --> RT[server.registerTool] tg --> RT texp --> RT uh1 --> RT uh2 --> RP[server.registerPrompt] uh3 --> RR[server.registerResource] endNew: sanitization during boot.
flowchart TB subgraph bootNew["Once at boot"] cfg[plugin config + builtinTools registry] --> san[sanitizeMCPConfig] san --> items["items: MCPItem[]"] end subgraph perReqNew["Per request"] req[POST /api/mcp] --> auth["getAuthorizedMCP<br/>fetch API key document, filter flat items[] array"] access["api-key.access JSON field"] --> auth auth --> build["buildMcpServer<br/>one switch on item.type"] build --> RT[server.registerTool] build --> RP[server.registerPrompt] build --> RR[server.registerResource] end items --> authThe MCP plugin is now also enabled across the monorepo test suites, so we exercise it internally.