Mastra + Payload MCP: Build a CMS Assistant in Your Payload Admin Panel

Embed a full AI assistant chat with sessions, memory, and approval-gated tool calls directly into your Payload admin panel — using Mastra, the Payload MCP plugin, and the same MongoDB your CMS already uses.

·12 min read
mastra+payload logo

What if your CMS admin could answer questions about its own data? Not a scripted search box — a real agent that reads your collections, explains what it finds, and asks for permission before it changes anything. And what if that whole experience lived inside the admin panel you already log into, using the same database you already run?

This tutorial builds exactly that: an AI assistant embedded in a Payload CMS admin panel. The agent is powered by Mastra, it talks to your collections through the Payload MCP plugin, every logged-in admin gets their own private sessions, and conversation memory lives in the same MongoDB your CMS already uses. No separate frontend, no second database, no second login.

What you will build

  • A Mastra agent (cms-agent) that can query your collections and, with approval, create or update records
  • Collections exposed as MCP tools via @payloadcms/plugin-mcp
  • Chat history and sessions stored in MongoDB through Mastra's MongoDBStore
  • Per-admin session isolation (each user sees only their own threads)
  • A chat UI rendered as a Payload admin view, themed by Payload's own design tokens

Throughout the tutorial I will use a small e-commerce example with products, orders and customers collections. Swap in whatever collections your own project has — the pattern is identical.

1. Project setup and dependencies

Start from any Payload 3 + Next.js project (App Router) with a MongoDB database. Payload 3.87.x is used here. Then install the Mastra packages, the AI SDK packages for streaming, and the Payload MCP plugin:

npmbash
1npm install @mastra/core @mastra/mcp @mastra/memory @mastra/mongodb @mastra/ai-sdk ai @ai-sdk/react @payloadcms/plugin-mcp

The two key pieces: @mastra/mcp lets the agent connect to MCP servers (here, your own CMS), and @mastra/ai-sdk bridges Mastra's agent runtime to the Vercel AI SDK protocol so the chat UI can stream messages with tool-call parts.

2. Expose your collections as MCP tools

The Payload MCP plugin turns collections into Model Context Protocol tools. Configure it in payload.config.ts. For each collection you can choose which operations to expose (find, create, update) and, crucially, write a description the agent will read to understand what the collection holds and how to create records correctly:

typescript
1import { buildConfig } from "payload";
2import { mongooseAdapter } from "@payloadcms/db-mongodb";
3import { mcpPlugin } from "@payloadcms/plugin-mcp";
4
5export default buildConfig({
6 // ... your collections: products, orders, customers ...
7 db: mongooseAdapter({ url: process.env.DATABASE_URI || "" }),
8
9 plugins: [
10 mcpPlugin({
11 collections: {
12 products: {
13 enabled: { find: true, create: true, update: true },
14 description:
15 "Products in the catalog. Required to create: name, price, category.",
16 },
17 orders: {
18 enabled: { find: true, create: true, update: true },
19 description:
20 "Customer orders. Required to create: customer (a valid customers id), items[] and total.",
21 },
22 customers: {
23 enabled: { find: true, create: true, update: true },
24 description: "Registered customers with contact info and order history.",
25 },
26 },
27 }),
28 ],
29});

The plugin exposes the MCP endpoint at /api/mcp — in a Next.js + Payload app this is handled automatically by the standard Payload REST catch-all route (src/app/(payload)/api/[...slug]/route.ts), so no extra route file is needed. Each collection becomes namespaced tools like findProducts, createOrders, updateCustomers, and so on.

Tip: the descriptions matter a lot. They are the agent's manual. Include required fields, relationships (a valid id from which collection), and any business rules or hooks that run on write.

3. Define the Mastra agent

Create a Mastra instance with a single agent. The agent's instructions describe what the MCP tools do and, importantly, the safety rules: reads are always allowed, writes must wait for human approval.

typescript
1// src/mastra/index.ts
2import { Mastra } from "@mastra/core";
3import { mastraStore } from "./memory";
4import { cmsAgent } from "./agents/cms-agent";
5
6export const mastra = new Mastra({
7 agents: { cmsAgent },
8 storage: mastraStore,
9});

typescript
1// src/mastra/agents/cms-agent.ts
2import { Agent } from "@mastra/core/agent";
3import { memory } from "../memory";
4
5export const cmsAgent = new Agent({
6 id: "cms-agent",
7 name: "CMS Assistant",
8 // The model can be overridden per request via requestContext
9 model: ({ requestContext }) => {
10 const selected = requestContext?.get?.("model");
11 return typeof selected === "string" && selected
12 ? selected
13 : "your-provider/your-default-model";
14 },
15 memory,
16 instructions: `
17You are the AI assistant for this CMS.
18
19The tools available to you come from the Payload MCP server and are
20namespaced with the prefix "payload_". Collections you can access:
21products, orders, customers.
22
23Rules:
241. Reads are safe. Use the find tools (findProducts, findOrders,
25 findCustomers) directly for lookups.
262. Writes are dangerous and NEVER auto-approved. If you need to create
27 or update a record, the platform requires human approval for every
28 create/update tool call. Explain what you intend to do and wait for
29 the approval in the chat.
303. Be professional, concise and helpful. Answer in the language the
31 user writes in.
32`.trim(),
33});

Two details worth calling out. First, the model resolver reads a "model" key from the request context, which lets the UI expose a model picker without redeploying — the chat request simply passes the chosen model id and the agent uses it for that run. Second, the instructions double as the agent's guardrails: because MCP tool approval is enforced on the server, the agent is told to narrate its intent and wait.

4. Memory on the same MongoDB

Here is where things get nice. Payload already stores your content in MongoDB via mongooseAdapter. Mastra's MongoDBStore accepts the same DATABASE_URI environment variable, so conversation memory lands in the same database — just a separate database name (mastra by default) inside it. Zero extra infrastructure.

typescript
1// src/mastra/memory.ts
2import { Memory } from "@mastra/memory";
3import { MongoDBStore } from "@mastra/mongodb";
4
5export const mastraStore = new MongoDBStore({
6 id: "mastra-storage",
7 uri: process.env.DATABASE_URI || "", // same MongoDB as Payload!
8 dbName: process.env.MASTRA_DB_NAME || "mastra",
9});
10
11export const memory = new Memory({
12 storage: mastraStore,
13 options: {
14 lastMessages: 20, // context window of recent history
15 generateTitle: true, // auto-title each session from the first message
16 },
17});

generateTitle is a small delight: after the first exchange, Mastra generates a title for the thread asynchronously, which the UI polls and shows in the sessions list.

5. The chat API route

The heart of the integration is a Next.js route handler at /api/chat. It does four things: authenticate the caller with the existing Payload session, create a per-request MCP client pointed at your own /api/mcp endpoint, stream the agent's run with Mastra's handleChatStream, and scope everything to a thread owned by the logged-in admin.

typescript
1// src/app/api/chat/route.ts
2import configPromise from "@payload-config";
3import { handleChatStream } from "@mastra/ai-sdk";
4import { MCPClient } from "@mastra/mcp";
5import { RequestContext } from "@mastra/core/request-context";
6import { mastra } from "@/mastra";
7import { getPayload } from "payload";
8
9export const runtime = "nodejs";
10const RESOURCE_ID = "cms";
11
12// The admin is already logged into Payload - reuse their session.
13async function authenticate(request: Request) {
14 const payload = await getPayload({ config: configPromise });
15 try {
16 const { user } = await payload.auth({ headers: request.headers });
17 if (user?.collection === "users") return { id: user.id };
18 return null;
19 } catch {
20 return null;
21 }
22}
23
24// One thread per admin, e.g. "admin-64f1c2..."
25const threadIdFor = (user: { id: string | number }) => `admin-${user.id}`;
26
27function createMcpClient(user: { id: string | number }, request: Request) {
28 return new MCPClient({
29 id: `mcp-${user.id}-${crypto.randomUUID()}`,
30 servers: {
31 payload: {
32 // Connect to the same app's MCP endpoint
33 url: new URL("/api/mcp", request.url),
34 requestInit: {
35 headers: {
36 Authorization: `Bearer ${process.env.MCP_API_KEY || ""}`,
37 },
38 },
39 // Writes require approval; pure finds run automatically
40 requireToolApproval: ({ toolName }) => {
41 const base = toolName.split(/[._]/).pop() ?? toolName;
42 return !base.startsWith("find");
43 },
44 },
45 },
46 });
47}
48
49export async function POST(request: Request) {
50 const user = await authenticate(request);
51 if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
52
53 const mcp = createMcpClient(user, request);
54 const toolsets = await mcp.listToolsets();
55
56 const params = await request.json();
57 const threadId = threadIdFor(user);
58
59 // Forward UI data (model, threadId) to the agent via requestContext
60 const requestContext = new RequestContext();
61 if (params.data && typeof params.data === "object") {
62 for (const [key, value] of Object.entries(params.data)) {
63 requestContext.set(key, value);
64 }
65 }
66
67 const rawStream = await handleChatStream({
68 mastra,
69 agentId: "cms-agent",
70 version: "v6",
71 params: {
72 ...params,
73 memory: { thread: threadId, resource: RESOURCE_ID },
74 requestContext,
75 },
76 defaultOptions: { toolsets },
77 });
78
79 // Wrap the stream so the per-request MCP connection is only closed after
80 // the agent run (and its tool calls) has fully completed.
81 const stream = new ReadableStream({
82 async start(controller) {
83 const reader = rawStream.getReader();
84 try {
85 while (true) {
86 const { done, value } = await reader.read();
87 if (done) break;
88 controller.enqueue(value);
89 }
90 controller.close();
91 } catch (error) {
92 controller.error(error);
93 } finally {
94 await mcp.disconnect().catch(() => undefined);
95 }
96 },
97 cancel() {
98 rawStream.cancel().catch(() => undefined);
99 mcp.disconnect().catch(() => undefined);
100 },
101 });
102
103 return createUIMessageStreamResponse({ stream });
104}
105
106// GET /api/chat?threadId=... recalls the thread's messages for the UI
107export async function GET(request: Request) {
108 const user = await authenticate(request);
109 if (!user) return Response.json({ error: "Unauthorized" }, { status: 401 });
110
111 const threadId = threadIdFor(user);
112 const agent = mastra.getAgentById("cms-agent");
113 const memory = await agent.getMemory();
114
115 const { messages } = await memory?.recall({ threadId, resourceId: RESOURCE_ID }) ?? {};
116 return Response.json(toAISdkV5Messages(messages ?? []));
117}

Why the stream wrapper matters: the raw stream has no reliable completion signal, and Next's after() fires as soon as the response starts streaming — which would close the MCP connection while the agent is still executing tool calls. Reading the stream to completion before disconnecting keeps tool execution safe.

6. Sessions API

A small companion route at /api/chat/threads manages sessions. The naming convention (admin-<userId>) is what makes isolation work: an admin can only ever list, open or delete threads whose id starts with their own prefix, so every user gets a private history even though all threads live in the same database.

typescript
1// src/app/api/chat/threads/route.ts (core)
2export async function GET(request: Request) {
3 const user = await authenticate(request); // same payload.auth() helper
4 const prefix = `admin-${user.id}`;
5 const agent = mastra.getAgentById("cms-agent");
6 const memory = await agent.getMemory();
7
8 const { threads } = await memory.listThreads({ perPage: false });
9 const own = (threads ?? [])
10 .filter((t) => t.resourceId === "cms" && t.id.startsWith(prefix))
11 .sort((a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime());
12
13 // paginate and return { threads, total, page, hasMore }
14}
15
16export async function POST() {
17 const user = await authenticate(request);
18 // new session id: admin-<userId>-<timestamp>
19 return Response.json({ threadId: `admin-${user.id}-${Date.now()}` });
20}
21
22export async function DELETE(request: Request) {
23 const user = await authenticate(request);
24 const threadId = new URL(request.url).searchParams.get("threadId");
25 if (!threadId || !threadId.startsWith(`admin-${user.id}`)) {
26 return Response.json({ error: "Invalid or unauthorized thread" }, { status: 400 });
27 }
28 const memory = await (await mastra.getAgentById("cms-agent")).getMemory();
29 await memory.deleteThread(threadId);
30 return Response.json({ deleted: true });
31}

You will also want a /api/chat/clear route (delete all messages in a thread) and /api/chat/undo (delete the last user message and its reply) — both are simple memory.deleteMessages calls that power the /clear and /undo slash commands in the UI.

7. Human-in-the-loop approvals

Because requireToolApproval is set on the MCP client, any non-find tool call suspends the agent's run instead of executing. The UI surfaces a card showing the exact arguments; when the admin clicks Approve or Decline, this route resumes or aborts the run:

typescript
1// src/app/api/chat/approve/route.ts
2export async function POST(request: Request) {
3 const user = await authenticate(request);
4 const threadId = `admin-${user.id}`;
5 const { approved, toolCallId } = await request.json();
6
7 const agent = mastra.getAgentById("cms-agent");
8 const { runs } = await agent.listSuspendedRuns({ threadId, resourceId: "cms" });
9 const run = runs[0];
10 const toolCall = toolCallId
11 ? run?.toolCalls.find((c) => c.toolCallId === toolCallId)
12 : run?.toolCalls[0];
13
14 if (!run || !toolCall) {
15 return Response.json({ error: "No pending approval found" }, { status: 404 });
16 }
17
18 const resumed = approved
19 ? await agent.approveToolCall({ runId: run.runId, toolCallId: toolCall.toolCallId })
20 : await agent.declineToolCall({ runId: run.runId, toolCallId: toolCall.toolCallId });
21
22 return Response.json({ text: (await resumed.text) || "", approved });
23}

This pattern is what makes an admin-facing agent trustworthy: the model can browse anything, but it cannot mutate anything without an explicit human decision in the loop.

8. The UI: a Payload admin view

Finally, register the chat component as a custom admin view so it appears at /admin/chat. This is a plain Payload admin view — the whole thing is one React component using the AI SDK's useChat hook with a DefaultChatTransport pointed at /api/chat:

typescript
1// payload.config.ts - register the view
2admin: {
3 components: {
4 views: {
5 aiAssistant: {
6 Component: "/components/admin/ChatUI#default",
7 path: "/chat",
8 },
9 },
10 },
11},

typescript
1// src/components/admin/ChatUI.tsx
2"use client";
3
4import { useChat } from "@ai-sdk/react";
5import { useConfig } from "@payloadcms/ui"; // Payload's own UI package
6import { DefaultChatTransport } from "ai";
7
8export default function ChatUI() {
9 const { config } = useConfig();
10 const adminRoute = config.routes.admin; // "back to admin" link
11
12 const { messages, setMessages, sendMessage, status, stop } = useChat({
13 transport: new DefaultChatTransport({
14 api: "/api/chat",
15 prepareSendMessagesRequest: ({ messages }) => ({
16 body: { messages, data: { model, threadId: activeThreadId } },
17 }),
18 }),
19 });
20
21 // Style everything with Payload's own design tokens so the chat
22 // matches the admin theme (light and dark) with zero extra work:
23 // var(--theme-bg), var(--theme-elevation-100), var(--theme-text),
24 // var(--style-radius-m), var(--font-mono), ...
25}
26

The component renders a sessions sidebar (loaded from /api/chat/threads), a message list where assistant replies are markdown-rendered, collapsible tool cards for every tool call the agent makes (showing input/output JSON and a state chip: streaming, waiting approval, completed, error), an approval banner with the raw arguments when a write is pending, a model picker that is sent through requestContext, and slash commands (/undo, /new, /clear, /help). Since it is a Payload view, the admin's session cookie authenticates every fetch automatically.

Why this setup is great

  • The UI is a Payload admin view. It inherits the admin theme, navigation, authentication and layout. Styling with Payload's CSS variables means light/dark mode and future design changes are picked up for free. No separate frontend to build or deploy.
  • One MongoDB for everything. Payload's mongooseAdapter and Mastra's MongoDBStore share the same DATABASE_URI. Your content and your conversation memory live in the same database — easy backups, easy deploys, no extra service to run.
  • No second login. The route authenticates with payload.auth() against the admin's existing session, and thread ids are namespaced per user (admin-<userId>), so each admin gets private sessions with zero extra auth code.
  • Safe by default. Reads run automatically; every create/update is suspended and presented to the human for approval, with the exact arguments visible.
  • Sessions with personality. Auto-generated titles, 20-message context, per-thread recall — switching between sessions feels like switching conversations in any chat app.

Wrap-up

You now have an AI assistant that lives inside your admin panel, understands your data model through MCP, remembers conversations per admin, and never writes without asking. From here the natural next steps are: add more collections or globals to the MCP plugin, expose read-only views for reporting, or point the agent at a RAG store so it can answer questions about your documentation too. The plumbing — MCP bridge, memory, sessions, approvals, themed UI — stays exactly the same.

Share:
Mouktar Aden

By Mouktar Aden