Skip to content

fix(plugin-mcp): create/update tools crash the MCP endpoint under TypeScript 6 ("use strict" prologue)#17109

Open
ctsstc wants to merge 2 commits into
payloadcms:3.xfrom
ctsstc:fix/plugin-mcp-ts6-use-strict
Open

fix(plugin-mcp): create/update tools crash the MCP endpoint under TypeScript 6 ("use strict" prologue)#17109
ctsstc wants to merge 2 commits into
payloadcms:3.xfrom
ctsstc:fix/plugin-mcp-ts6-use-strict

Conversation

@ctsstc

@ctsstc ctsstc commented Jun 25, 2026

Copy link
Copy Markdown

What & why

Fixes #16339 (and the duplicate #16743). On the 3.x line, every @payloadcms/plugin-mcp consumer using TypeScript 6 gets a 500 on the first POST /api/mcp request whenever an API key has create or update enabled on a collection — no tools register at all:

APIError: Error registering tools for collection <slug>: TypeError: convertedFields.partial is not a function
  status: 500

Root cause

convertCollectionSchemaToZod builds the Zod schema by transpiling a generated source string and evaling it:

const transpileResult = ts.transpileModule(zodSchemaAsString, { compilerOptions: { module: CommonJS, ... } })
return new Function('z', `return ${transpileResult.outputText}`)(z)

TypeScript 6 prepends a "use strict"; directive to the transpiled module output that TypeScript 5 did not. The wrapper then evaluates as:

function (z) {
  return "use strict";          // ← returns the STRING
  z.object({ ... }).strict();   // ← unreachable
}

so the function returns the string "use strict" instead of a ZodObject. resource/update.ts (and resource/create.ts, global/update.ts) then call .partial() / .shape on it and throw. That throw propagates out of getMcpHandler's per-collection try/catch as APIError(500), aborting tool registration for the whole endpoint.

Secondary: the existing catch fallback returned z.record(z.any()), a ZodRecord that also lacks .partial()/.shape, so genuine conversion failures broke the same callers.

Why is CI green? test/plugin-mcp/config.ts already enables update: true (on field-types), but the suite only trips this under TypeScript 6 — the prologue isn't emitted on TS 5. A minimal TS 6 reproduction: https://github.com/jhb-dev/payload-mcp-ts6-use-strict

The fix (convertCollectionSchemaToZod.ts)

  1. Strip the leading "use strict"; directive and the whitespace that follows it before the return ${...} wrapper. The transpiled output is "use strict";\n<expr>; stripping only the directive leaves return \n<expr>, where ASI inserts a semicolon after return and the function returns undefined (→ an empty input schema, so create/update silently accept no fields). Consuming the trailing whitespace keeps the expression on the return line.
  2. Guard the result: return z.object({}).passthrough() if the eval somehow didn't produce a ZodObject, so callers that use .partial()/.shape can't crash.
  3. Change the catch fallback from z.record(z.any()) to z.object({}).passthrough() for the same reason.

No call sites change — centralizing the guarantee in the converter keeps the create/update/global tools safe.

Relation to main / v4

main (v4) is not affected: the breaking plugin refactor in #16726 replaced the transpile-and-eval conversion (convertCollectionSchemaToZodnew Function('z', 'return ' + tsTranspiled)(z)) with JSON-Schema-based input generation via Payload core's entityToStandaloneJSONSchema + a new toStandardSchema helper, so a "use strict" prologue can no longer reach an eval. That fix is a breaking refactor (note the ! in its title) and isn't cherry-pickable onto 3.x — and there is no smaller targeted commit (the 3.x history on this file is only #16114, which added the z.record fallback, #15705, and #15660). This PR is the minimal equivalent for the 3.x line: keep the existing conversion, just stop the prologue from poisoning the eval and guarantee a populated ZodObject result.

Testing

I wasn't able to run the full monorepo suite in my environment, so I scoped this to the source fix, but I verified the behavior end-to-end by applying it (via patch-package) to a downstream app on 3.84.1 + typescript@6.0.3 with a heavily block-based collection:

  • Before: POST /api/mcp 500s on first request when a key grants update.
  • With only step 1's directive stripped (no trailing whitespace): no 500, but the converted schema is undefined→empty, so the update tool registers with no field properties and silently drops all field data.
  • With the full fix: convertCollectionSchemaToZod returns a populated ZodObject, the update tool's input schema includes the collection's fields, and an MCP update call persists field changes (verified against the DB).

Happy to add a regression test (a TS 6 CI matrix entry, or a unit test asserting convertCollectionSchemaToZod(...) instanceof z.ZodObject and that its .shape is non-empty) in whatever form you prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant