Skip to content

feat(cms): add generateThumbnail MCP tool#37

Merged
jhb-dev merged 3 commits into
mainfrom
feat/mcp-generate-thumbnail-tool
Jun 25, 2026
Merged

feat(cms): add generateThumbnail MCP tool#37
jhb-dev merged 3 commits into
mainfrom
feat/mcp-generate-thumbnail-tool

Conversation

@jhb-dev

@jhb-dev jhb-dev commented Jun 24, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Extracts generateThumbnailCore() from the REST endpoint handler so the logic is shared without duplication
  • Adds a generateThumbnail top-level MCP tool to mcpPlugin in payload.config.ts, using defineTool from @payloadcms/plugin-mcp
  • The REST endpoint (POST /api/generate-thumbnail) is unchanged in behavior — it now delegates to the shared core function

Why

The /generate-thumbnail endpoint existed only as a REST API, meaning agents using the MCP protocol (like the OpenClaw dev agent) had to call it via HTTP rather than as a native tool. This PR makes it a first-class MCP tool alongside the collection/global CRUD tools.

Other endpoints without MCP tools

Checked the remaining custom endpoints — none are candidates for MCP tools:

  • GET /static-paths — frontend build artifact, not content authoring
  • GET /sitemap — frontend build artifact
  • GET /page-props — frontend preview mode helper
  • GET /global-data — frontend data consolidation endpoint

Test plan

  • Deploy CMS and verify generateThumbnail appears in the MCP tool list
  • Call the tool via MCP client (e.g. OpenClaw), confirm thumbnail is generated and linked to an article
  • Verify the REST endpoint still works as before (POST /api/generate-thumbnail)

🤖 Generated with Claude Code

Exposes the existing /generate-thumbnail endpoint as a first-class MCP
tool so AI agents (e.g. OpenClaw) can generate branded article thumbnails
directly via the MCP protocol without going through HTTP.

Refactored generateThumbnailCore() out of the REST handler so both the
endpoint and the MCP tool share the same logic without duplication.

Co-Authored-By: JHB-Claw <info@jhb.software>
@vercel

vercel Bot commented Jun 24, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

2 Skipped Deployments
Project Deployment Actions Updated (UTC)
website Ignored Ignored Jun 25, 2026 5:00am
website-cms Ignored Ignored Jun 25, 2026 5:00am

Request Review

- Replace defineTool/tools (main-branch API) with mcp.tools array (v3.85.0 API)
- Use z.ZodRawShape for parameters instead of JSON Schema input
- Add z import from zod
- Fix line lengths to pass Prettier check

Co-Authored-By: JHB-Claw <info@jhb.software>
@jhb-dev jhb-dev changed the title feat(cms/mcp): add generateThumbnail MCP tool feat(cms): add generateThumbnail MCP tool Jun 24, 2026
@claude

claude Bot commented Jun 24, 2026

Copy link
Copy Markdown

Code Review

This PR extracts a generateThumbnailCore() shared function and exposes thumbnail generation as a first-class MCP tool via defineTool. The refactor is clean and the MCP handler mirrors the REST endpoint's error handling pattern correctly — two findings survived verification.


Bug — 404 error body is a JSON string literal, not an error object

cms/src/endpoints/generateThumbnail.ts (new REST handler, error path)

if ('error' in result) {
  return new Response(
    JSON.stringify('image' in result ? { error: result.error, image: result.image } : result.error),
    { headers: { 'Content-Type': 'application/json' }, status: result.status },
  )
}

When articleId lookup fails (404), generateThumbnailCore returns { error: 'Article X not found', status: 404 } — no image key. The condition 'image' in result is false, so the body becomes JSON.stringify(result.error). Since result.error is a plain string, JSON.stringify wraps it in quotes: the HTTP body is "Article abc123 not found" (a JSON string literal), not {"error":"Article abc123 not found"}.

The response carries Content-Type: application/json but delivers a bare string primitive. Any client that reads .error from the parsed JSON gets undefined. The 502 path is fine — it correctly wraps in an object — making this shape inconsistent between the two error cases.

Fix: replace result.error with { error: result.error } in the else branch:

JSON.stringify('image' in result ? { error: result.error, image: result.image } : { error: result.error })

Bug — MCP path accepts empty title/subtitle, silently generating blank thumbnails

cms/src/payload.config.ts (MCP tool schema)

The JSON schema marks title and subtitle as required but has no minLength constraint — an empty string "" is valid per schema. The MCP handler casts input.title as string and input.subtitle as string without any trim or non-empty check, and generateThumbnailCore passes them straight to buildThumbnailSvg.

Calling via REST with title: "" returns a 400. Calling via MCP with title: "" uploads a thumbnail with a blank title area, linked to the article, with no error returned.

Fix: add minLength: 1 to both properties in the schema, or move the non-empty guard into generateThumbnailCore itself so all callers are covered uniformly:

title: {
  description: 'Main title text (max ~3 lines at large font size).',
  type: 'string',
  minLength: 1,
},
subtitle: {
  description: 'Subtitle text rendered below the title (max ~2 lines).',
  type: 'string',
  minLength: 1,
},

@claude

claude Bot commented Jun 24, 2026

Copy link
Copy Markdown

Review: feat(cms): add generateThumbnail MCP tool

This PR cleanly extracts generateThumbnailCore() to share rendering logic between the existing REST endpoint and the new MCP tool. The refactor is well-structured and auth for the MCP handler is correctly delegated to the @payloadcms/plugin-mcp session layer (not a gap). Three issues to address before merging:


Bug — REST 404 error body changed from plain text to a bare JSON string

cms/src/endpoints/generateThumbnail.ts:405

const body = 'image' in result ? { error: result.error, image: result.image } : result.error
return new Response(JSON.stringify(body), { headers: { 'Content-Type': 'application/json' }, status: result.status })

For a 404 (article not found), 'image' in result is false, so body = result.error — a plain string. JSON.stringify("Article X not found") produces '"Article X not found"' (with surrounding quotes), served as application/json. The old endpoint returned a plain-text body with no Content-Type header. Any caller doing response.text() now gets extra quotes; any caller doing response.json() gets a string value rather than an object.

Fix: Use a consistent shape for all error paths — { error: result.error } — so the branch is simply:

const body = 'image' in result ? { error: result.error, image: result.image } : { error: result.error }

Bug — MCP handler silently discards image metadata on partial 502 failure

cms/src/payload.config.ts:294

if ('error' in result) {
  return text(result.error)
}

When the thumbnail is uploaded successfully but article linking fails, generateThumbnailCore returns { error: "...", image: { filename, id, url }, status: 502 }. The MCP handler returns only the error string and silently drops the image field. An MCP agent that hits this path has no way to retrieve or re-link the orphaned image, even though the REST endpoint faithfully returns the image metadata in this case.

Fix:

if ('error' in result) {
  const payload = 'image' in result ? { error: result.error, image: result.image } : result.error
  return text(JSON.stringify(payload))
}

Bug — MCP tool accepts empty title / subtitle without validation

cms/src/payload.config.ts:319–326

z.string() permits "". The REST endpoint trims both fields and returns 400 if either is empty. The MCP handler casts args as GenerateThumbnailInput and passes directly to generateThumbnailCore with no trimming or empty-string check. Sending { title: "", subtitle: "Sub" } produces a valid-but-blank thumbnail uploaded to the images collection; the filename falls back to article.webp, and the alt text becomes — Sub. No error is returned.

Fix: Add .min(1) to the title and subtitle Zod schemas (.trim() is also worth considering):

title: z.string().min(1).describe('...'),
subtitle: z.string().min(1).describe('...'),

Nits

  • cms/src/endpoints/generateThumbnail.ts:40type GenerateThumbnailBody = GenerateThumbnailInput is a dead one-liner alias used only to annotate let body. Just write let body: GenerateThumbnailInput and drop the alias.

  • cms/src/payload.config.ts:300–327 — The Zod parameters block and GenerateThumbnailInput declare the same seven fields with no compile-time link. Adding a field to the type won't produce an error in the schema. Consider deriving the type from the schema: type GenerateThumbnailInput = z.infer<typeof generateThumbnailSchema>.

  • cms/src/endpoints/generateThumbnail.ts:400 — The REST catch block logs 'Failed to render thumbnail SVG' for all errors from generateThumbnailCore, including storage/DB errors from payload.create() that happen after rendering succeeds. A more generic message ('generateThumbnail failed') avoids misleading diagnostics.


🤖 Generated with Claude Code

The MCP plugin types tool `parameters` against zod v3 (its bundled MCP SDK
peers zod 3.x) while the project uses zod v4. Assigning a v4 shape to the v3
`ZodRawShape` field made TypeScript deep-compare the recursive `ZodType` and
fail with TS2589. Build the parameter shape with the project's zod v4 (the MCP
SDK runtime accepts both v3 and v4) and bridge the type at a single plugin
boundary via `MCPToolParameters`.

Also address review feedback on the generateThumbnail tool/endpoint:

- REST 404 error body now uses a consistent `{ error }` JSON shape instead of
  a bare JSON-stringified string.
- MCP handler preserves the orphaned image metadata on a partial (502) failure
  instead of returning only the error string.
- MCP tool rejects empty/whitespace `title` and `subtitle` via `.trim().min(1)`,
  matching the REST endpoint's validation.
- Drop the dead `GenerateThumbnailBody` type alias.
- Use a generic catch-log message that covers storage/DB failures, not just SVG
  rendering.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B6NdVGhmt9RpKB6kNmN3Hr
@claude

claude Bot commented Jun 25, 2026

Copy link
Copy Markdown

Code Review

This PR cleanly extracts generateThumbnailCore() to share logic between the REST endpoint and the new MCP tool, and the MCP tool wiring in payload.config.ts is well-structured. Three issues worth addressing before merge:


1. payload.create failure misclassified as a render error (CONFIRMED)

cms/src/endpoints/generateThumbnail.ts:328

req.payload.create(...) (the image upload) is outside any try/catch inside generateThumbnailCore. The function's documented contract says it "throws on rendering failure, returns { error, status } for domain errors" — but an image upload failure (S3 outage, DB error) is a domain error that currently escapes as an untyped throw instead of a structured { error, status } return.

Both callers catch it, so there's no unhandled rejection. But the REST caller logs it as 'generateThumbnail failed' and the MCP caller logs it as 'MCP generateThumbnail: render failed' — an upload failure is misidentified as a render failure, which makes on-call debugging harder.

Fix: Wrap payload.create in a try/catch inside generateThumbnailCore and return { error: '...', status: 500 } instead of throwing, consistent with the existing findByID and update error paths.


2. Zod v4 schema passed to a plugin that resolves zod v3 at runtime (PLAUSIBLE)

cms/src/payload.config.ts:206

The comment at line 126 explains the v3/v4 type mismatch and states "the MCP SDK runtime accepts both v3 and v4 schemas." This is supported by the MCP SDK's published peer dependency range (zod: ^3.25 || ^4.0), but the lockfile resolves @payloadcms/plugin-mcp@3.85.0's internal MCP SDK to zod@3.25.76. The schema object built from the project's zod@4.4.3 is a different module instance than the one the plugin's runtime sees.

Whether this works at runtime depends on whether the MCP SDK calls zodToJsonSchema() (safe — uses the JSON Schema representation, not the zod internals) or direct .parse() / instanceof checks (unsafe — zod v4 objects fail v3 instanceof checks). If validation silently short-circuits, malformed MCP tool calls that should be rejected by subtitle: z.string().min(1) would pass through unchecked.

Suggested action: Add a smoke test or a quick manual check that generateThumbnail appears correctly in the MCP tool list AND that calling it without a subtitle returns an InvalidParams error from the MCP layer (rather than a render failure or silent success). If the SDK uses zod-to-json-schema, this is a non-issue; if it doesn't, the schema needs to be built with the plugin-bundled zod instance.


3. Variable shadowing in the REST handler (minor)

cms/src/endpoints/generateThumbnail.ts:403

let body: GenerateThumbnailInput   // line 382 — the parsed request body

// ... later inside the if block:
const body =                        // line 403 — shadows the outer body
  'image' in result ? { error: result.error, image: result.image } : { error: result.error }

The inner const body shadows the outer let body. TypeScript allows this (no no-shadow error unless the rule is enabled), but a future edit that expects body to be the original GenerateThumbnailInput — e.g. to log body.articleId in the error path — would silently read { error, image? } instead.

Fix: Rename the inner variable, e.g. const errorBody = ....


Not a bug (verified): The missing req.user check in the MCP handler is intentional — @payloadcms/plugin-mcp enforces API-key auth via Payload's middleware (the payload-mcp-api-keys collection with useAPIKey: true) before any tool handler fires, so req.user is always populated for valid MCP calls. restrictMcpToDraft.ts confirms this by safely branching on req.user?.collection === 'payload-mcp-api-keys'.

🤖 Generated with Claude Code

@jhb-dev
jhb-dev merged commit 2592ea9 into main Jun 25, 2026
16 checks passed
@jhb-dev
jhb-dev deleted the feat/mcp-generate-thumbnail-tool branch June 27, 2026 15:11
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.

2 participants