
Your Payload MCP edits never show up on the site
Content saved through Payload's MCP plugin doesn't reach your statically rendered Next.js pages, and nothing reports an error. Here's why, and a short wrapper that fixes it.
I build two sites with Payload and Next.js: boraan.at, a small shop for organic Kampot pepper, and qrcardapp.com, a digital business card app. On both, I let AI agents edit content through Payload's MCP plugin. The tool calls succeeded and the admin panel showed the new text, but the live site kept the old version. There was no error in the logs. Edits made in the admin panel showed up fine, so the problem was limited to MCP.
The setup
My public pages are static and read Payload through the Local API. Each collection has an afterChange hook that revalidates the whole site, and pages re-render on their next visit. This works for every save made in the admin panel, and the MCP tools run the same hooks.
export const revalidateSiteAfterChange: CollectionAfterChangeHook = ({ doc }) => {
revalidatePath('/', 'layout');
return doc;
};Why the revalidation disappears
The MCP endpoint answers before the tool runs
The plugin builds its endpoint with mcp-handler, which uses the Streamable HTTP transport. The handler returns a text/event-stream response as soon as the stream opens. The tool, and with it the database write and your hooks, runs while the body streams.
Next.js applies revalidations when the handler returns
revalidatePath and revalidateTag don't purge anything immediately. Next.js collects them during a request and applies them once the route handler has returned. A call made after that point is dropped, and nothing throws or logs a warning. When I reproduced it locally, the handler returned about 120 ms before revalidatePath ran.
The fix: wait for the whole response
Payload's MCP tools send a single result and don't stream progress, so nothing is lost if the endpoint waits for the full body before returning:
export const awaitResponseBody =
(handler: PayloadHandler): PayloadHandler =>
async (request) => {
const response = await handler(request);
return new Response(await response.arrayBuffer(), response);
};Now the tool finishes inside the handler, and its revalidations land. To apply it, wrap the plugin and replace the handler of its POST /mcp endpoint:
import { mcpPlugin as payloadMcpPlugin } from '@payloadcms/plugin-mcp';
import type { PayloadHandler, Plugin } from 'payload';
export const mcpPlugin =
(options: Parameters<typeof payloadMcpPlugin>[0]): Plugin =>
async (incomingConfig) => {
const config = await payloadMcpPlugin(options)(incomingConfig);
return {
...config,
endpoints: config.endpoints?.map((endpoint) =>
endpoint.path === '/mcp' && endpoint.method === 'post'
? { ...endpoint, handler: awaitResponseBody(endpoint.handler) }
: endpoint,
),
};
};Then use this mcpPlugin in payload.config.ts instead of the original.
The MCP SDK could answer with plain JSON instead (enableJsonResponse), which avoids the problem, but mcp-handler doesn't expose that option, so buffering is the simplest fix you can apply yourself.
I've reported this to Payload in payloadcms/payload#18490. Until it's fixed in the plugin, the wrapper does the job.
A test that pins it down
A small Bun test simulates the endpoint: a response whose stream sets a flag after a short delay. The wrapper must only resolve once the flag is set. A second test checks that the empty 202 responses the endpoint sends for notifications stay empty.
test('returns once the streamed body is complete', async () => {
let toolFinished = false;
// Like the MCP endpoint: the response is returned first, the tool runs while the body streams
const handler = async () =>
new Response(
new ReadableStream({
async start(controller) {
await new Promise((resolve) => setTimeout(resolve, 10));
toolFinished = true;
controller.enqueue(new TextEncoder().encode('data: {"result":{}}\n\n'));
controller.close();
},
}),
{ headers: { 'Content-Type': 'text/event-stream' } },
);
const response = await awaitResponseBody(handler)({} as PayloadRequest);
expect(toolFinished).toBe(true);
expect(await response.text()).toBe('data: {"result":{}}\n\n');
});Stop swallowing revalidation errors
Many projects wrap revalidatePath in an empty try/catch because it throws outside a Next.js request, for example in seed scripts. That's how I wrote it too, and it's part of why this bug stayed hidden for a while. Logging a warning costs one line and makes the next silent failure visible:
try {
revalidatePath('/', 'layout');
} catch (error) {
// Expected outside of a Next.js request (e.g. in scripts), where there is no cache to invalidate
req.payload.logger.warn({ err: error }, 'Could not revalidate the site');
}Takeaways
- Next.js only applies
revalidatePathandrevalidateTagif they run before the route handler returns. Work that happens after the response has started streaming can't revalidate. - Check this for every streaming endpoint whose work writes data, not just MCP.
- If your editors say "it saved but the site didn't change", compare when the handler returns with when your hooks run, before you start debugging the cache.
