Skip to main content

MCPServer

The MCPServer class provides the functionality to expose your existing Mastra tools and Agents as a Model Context Protocol (MCP) server. Any MCP client, such as Cursor, Windsurf, or Claude Desktop, can connect to these capabilities and make them available to an agent.

Note that if you only need to use your tools or agents directly within your Mastra application, you don't necessarily need to create an MCP server. This API is specifically for exposing your Mastra tools and agents to external MCP clients.

It supports the stdio (subprocess) and Streamable HTTP MCP transports of the MCP 2026-07-28 revision.

note

Upgrading from @mastra/mcp 1.x? See the migration guide.

Constructor
Direct link to Constructor

To create a new MCPServer, you need to provide some basic information about your server, the tools it will offer, and optionally, any agents you want to expose as tools.

import { Agent } from '@mastra/core/agent'
import { createTool } from '@mastra/core/tools'
import { MCPServer } from '@mastra/mcp'
import { z } from 'zod'
import { dataProcessingWorkflow } from '../workflows/dataProcessingWorkflow'

const myAgent = new Agent({
id: 'my-example-agent',
name: 'MyExampleAgent',
description: 'A generalist to help with basic questions.',
instructions: 'You are a helpful assistant.',
model: 'openai/gpt-6.1-sol',
})

const weatherTool = createTool({
id: 'getWeather',
description: 'Gets the current weather for a location.',
inputSchema: z.object({ location: z.string() }),
execute: async inputData => `Weather in ${inputData.location} is sunny.`,
})

const server = new MCPServer({
id: 'my-custom-server',
name: 'My Custom Server',
version: '1.0.0',
description: 'A server that provides weather data and agent capabilities',
instructions:
'Use the available tools to help users with weather information and data processing tasks.',
tools: { weatherTool },
agents: { myAgent }, // this agent will become tool "ask_myAgent"
workflows: {
dataProcessingWorkflow, // this workflow will become tool "run_dataProcessingWorkflow"
},
})

Tool schemas and structured results
Direct link to Tool schemas and structured results

MCP tool input and output schemas are advertised as JSON Schema 2020-12 with the dialect declared in $schema. Advertised Mastra tool schemas preserve supported references, composition keywords, and tuple (prefixItems) shapes.

A tool with an outputSchema can return any JSON value, including an object, array, string, number, boolean, or null. The value is sent as structuredContent without wrapping it in an object. The server validates successful structured output against the tool's output schema before returning it.

Configuration Properties
Direct link to Configuration Properties

The constructor accepts an MCPServerConfig object with the following properties:

id:

string
Unique identifier for the server. This ID is preserved when the server is registered with Mastra and can be used to retrieve the server via getMCPServerById().

name:

string
A descriptive name for your server (e.g., 'My Weather and Agent Server').

version:

string
The semantic version of your server (e.g., '1.0.0').

tools:

ToolsInput
An object where keys are tool names and values are Mastra tool definitions (created with createTool or Vercel AI SDK). These tools will be directly exposed.

agents?:

Record<string, Agent>
An object where keys are agent identifiers and values are Mastra Agent instances. Each agent will be automatically converted into a tool named ask_<agentIdentifier>. The agent **must** have a non-empty description string property defined in its constructor configuration. This description will be used in the tool's description. If an agent's description is missing or empty, an error will be thrown during MCPServer initialization.

workflows?:

Record<string, Workflow>
An object where keys are workflow identifiers and values are Mastra Workflow instances. Each workflow is converted into a tool named run_<workflowKey>. The workflow's inputSchema becomes the tool's input schema. The workflow **must** have a non-empty description string property, which is used for the tool's description. If a workflow's description is missing or empty, an error will be thrown. The tool executes the workflow by calling workflow.createRun() followed by run.start({ inputData: <tool_input> }). If a tool name derived from an agent or workflow (e.g., ask_myAgent or run_myWorkflow) collides with an explicitly defined tool name or another derived name, the explicitly defined tool takes precedence, and a warning is logged. Agents/workflows leading to subsequent collisions are skipped.

description?:

string
Optional description of what the MCP server does. Announced to MCP clients as part of the server's identity and returned in the registry server info.

title?:

string
Optional human-readable display title announced to MCP clients (e.g., 'Weather Server'). Clients fall back to name when it isn't set.

websiteUrl?:

string
Optional URL of the server's website, announced to MCP clients.

icons?:

{ src: string; mimeType?: string; sizes?: string[]; theme?: 'light' | 'dark' }[]
Optional icons clients can display for the server. src is an https: URL or a data: URI; sizes takes values like '48x48' or 'any'.

instructions?:

string
Optional instructions describing how to use the server and its features.

mapAuthInfoToUser?:

({ authInfo, extra, requestContext }) => unknown | null | undefined | Promise<unknown | null | undefined>
Maps MCP transport auth data from extra.authInfo into the user value used by Mastra FGA checks. Use this when an OAuth-protected MCP server is registered on a Mastra instance with an FGA provider.

fga?:

{ resourceMapping?: Partial<Record<'tool' | 'tools', { fgaResourceType: string; deriveId?: ({ user, resourceId, requestContext }) => string | undefined }>>; permissionMapping?: Record<string, string> }
Overrides resource and permission mappings for this MCP server's tools/list and tools/call FGA checks. Use this when MCP authorization should be scoped differently from internal agent or workflow tool execution.

requestState?:

{ key?: string | Uint8Array; ttlSeconds?: number }
Integrity protection for input_required continuation state. key is an HMAC key of at least 32 bytes that every instance able to answer a continuation must share; ttlSeconds (default 600) is how long a suspended round stays answerable. Without a key the server generates one per process. See the Asking the caller for input section.

cacheHints?:

MCPServerCacheHints
Cache hints (ttlMs / cacheScope) advertised on cacheable results, keyed by operation: 'tools/list', 'prompts/list', 'resources/list', 'resources/templates/list', 'resources/read' and 'server/discover'.

legacy?:

'reject' | 'stateless'
How HTTP requests from 2025-era clients (those that open with initialize, such as VS Code and Cursor) are served. 'reject' (default) answers them with an unsupported-protocol-version error. 'stateless' answers each request without a session: listing and calling tools, reading resources and getting prompts work, GET and DELETE session requests get 405, and features that need 2026-07-28 (suspend/resume, subscriptions/listen, events) aren't available to these clients.

jsonSchemaValidator?:

jsonSchemaValidator
Custom JSON Schema validator used for tool input, output and continuation answers, for runtimes where the SDK default is unavailable.

repository?:

Repository
Optional repository information for the server's source code.

releaseDate?:

string
Optional release date of this server version (ISO 8601 string). Defaults to the time of instantiation if not provided.

isLatest?:

boolean
Optional flag indicating if this is the latest version. Defaults to true if not provided.

packageCanonical?:

'npm' | 'docker' | 'pypi' | 'crates' | string
Optional canonical packaging format if the server is distributed as a package (e.g., 'npm', 'docker').

packages?:

PackageInfo[]
Optional list of installable packages for this server.

remotes?:

RemoteInfo[]
Optional list of remote access points for this server.

resources?:

MCPServerResources
An object defining how the server should handle MCP resources. See Resource Handling section for details.

prompts?:

MCPServerPrompts
An object defining how the server should handle MCP prompts. See Prompt Handling section for details.

appResources?:

AppResources
A map of ui:// URIs to app resource configurations. Each entry defines an interactive HTML UI served via the MCP Apps extension (SEP-1865). See the MCP Apps section for details.

Exposing agents as tools
Direct link to Exposing agents as tools

A powerful feature of MCPServer is its ability to automatically expose your Mastra Agents as callable tools. When you provide agents in the agents property of the configuration:

  • Tool Naming: Each agent is converted into a tool with the name ask_<agentKey>, where <agentKey> is the key you used for that agent in the agents object. For instance, if you configure agents: { myAgentKey: myAgentInstance }, a tool with the name ask_myAgentKey will be created.

  • Tool Functionality:

    • Description: The generated tool's description will be in the format: "Ask agent <AgentName> a question. Original agent instructions: <agent description>".
    • Input: The tool expects a single object argument with a message property (string): { message: "Your question for the agent" }.
    • Execution: When this tool is called, it invokes the corresponding agent's generate() method with the provided query.
    • Output: The direct result from the agent's generate() method is returned as the output of the tool.
  • Name collisions. If an explicit tool defined in the tools configuration has the same name as an agent-derived tool (e.g., a tool with the name ask_myAgentKey alongside an agent keyed as myAgentKey), the explicitly defined tool will take precedence. The agent won't be converted into a tool in this conflicting case, and a warning will be logged.

This makes it straightforward to allow MCP clients to interact with your agents using natural language queries, like any other tool.

Agent-to-Tool Conversion
Direct link to Agent-to-Tool Conversion

When you provide agents in the agents configuration property, MCPServer will automatically create a corresponding tool for each agent. The tool will be ask_<agentIdentifier>, where <agentIdentifier> is the key you used in the agents object.

The description for this generated tool will be: "Ask agent <agent.name> a question. Agent description: <agent.description>".

For an agent to be converted into a tool, it must have a non-empty description string property set in its configuration when it was instantiated (e.g., new Agent({ id: 'my-agent', name: 'myAgent', description: 'This agent does X.', ... })). If an agent is passed to MCPServer with a missing or empty description, an error will be thrown when the MCPServer is instantiated, and server setup will fail.

Clients can use MCP to access your agents' generative capabilities and ask them questions directly.

Accessing MCP context in tools
Direct link to Accessing MCP context in tools

Tools exposed through MCPServer receive the protocol context of the current request as context.mcp:

MemberDescription
context.mcp.extra.signalAborts when the client cancels the request or disconnects
context.mcp.extra.requestIdThe JSON-RPC id of the request
context.mcp.extra.authInfoWhatever the transport authenticated (see Authentication context)
context.mcp.extra._metaRequest metadata: trace headers, the log-level opt-in and the progress token
context.mcp.log()Sends a log message to the calling client (see Logging)
context.mcp.progress()Reports progress to the calling client (see Progress notifications)
context.mcp.protocolVersion'2026-07-28'

The same request also populates context.requestContext, the trusted application context, with authInfo, the user returned by mapAuthInfoToUser and the W3C traceContext the client sent. Tools invoked by an agent that an MCP client asked (ask_<agent>) don't receive context.mcp, but the request context is forwarded to the agent, so read auth data from there when a tool can be reached both ways:

const authInfo = context.mcp?.extra.authInfo ?? context.requestContext?.get('authInfo')

Example: Tool that uses caller identity with a separate credential
Direct link to Example: Tool that uses caller identity with a separate credential

import { createTool } from '@mastra/core/tools'
import { z } from 'zod'

import { apiCredentials } from '../lib/api-credentials'

const fetchUserData = createTool({
id: 'fetchUserData',
description: 'Fetches data for the authenticated MCP caller',
inputSchema: z.object({
resourceId: z.string().describe('The ID of the record to fetch'),
}),
execute: async (inputData, context) => {
const authInfo = context.mcp?.extra.authInfo ?? context.requestContext?.get('authInfo')
const user = authInfo?.extra?.user as { id?: string } | undefined

if (!user?.id) {
throw new Error('Authentication required')
}

// Resolve the owner from trusted context, not from model input
const credential = await apiCredentials.getForUser(user.id)

const response = await fetch(
`https://api.example.com/users/${encodeURIComponent(user.id)}/records/${encodeURIComponent(inputData.resourceId)}`,
{
headers: {
Authorization: `Bearer ${credential.token}`,
},
signal: context.mcp?.extra.signal,
},
)

return response.json()
},
})

Use authInfo for caller identity, then select a credential intended for the service the tool calls. The model supplies business input such as resourceId, while trusted code selects the credential. An MCP access token is for your MCP server, so it must not be forwarded to another API. See Access upstream services safely.

Where authInfo comes from
Direct link to where-authinfo-comes-from

When an MCP server is served by a Mastra server, extra.authInfo is populated from the principal resolved by server.auth. Nothing extra is required:

export const mastra = new Mastra({
mcpServers: { myServer },
server: {
auth: new MastraJwtAuth({ secret: process.env.JWT_SECRET! }),
},
})

The authenticated user is mapped to authInfo as:

authInfo fieldValue
tokenThe bearer token or session cookie value used for the request
clientIdThe first present of user.id, user.sub, user.userId, user.email
scopesuser.scopes, user.scope, or user.permissions, normalized to an array
extra.userThe full user object returned by the auth provider

If your own middleware performs the verification, set server.mcpOptions.setRequestAuth to build authInfo yourself. The hook replaces the default mapping:

export const mastra = new Mastra({
mcpServers: { myServer },
server: {
middleware: [verifyBearerToken], // stores the payload on the request context
mcpOptions: {
setRequestAuth: (req, requestContext) => {
const payload = requestContext.get('bearerPayload')
req.auth = {
token: payload.token,
clientId: payload.sub,
scopes: payload.scope.split(' '),
}
},
},
},
})

Leaving req.auth unset inside the hook opts the request out of auth info entirely.

Asking the caller for input
Direct link to Asking the caller for input

A tool that needs something from the user before it can finish calls context.suspend(payload) and returns, exactly as it would inside an agent or a workflow. The server ends the request as an input_required result that carries the form described by the tool's resumeSchema. When the client answers, the server runs the tool again with the answer in context.resumeData and the payload it suspended with in context.suspendPayload. Each round is a separate request: the server never replays earlier rounds, so put the state the next round needs in the payload and branch on it.

import { createTool } from '@mastra/core/tools'
import { z } from 'zod'

export const bookDelivery = createTool({
id: 'bookDelivery',
description: 'Books a delivery for an order after confirming the address.',
inputSchema: z.object({ orderId: z.string() }),
outputSchema: z.object({ confirmed: z.boolean() }),
suspendSchema: z.object({ phase: z.literal('address'), message: z.string() }),
resumeSchema: z.object({ address: z.string() }),
execute: async ({ orderId }, context) => {
if (!context.resumeData) {
await context.suspend?.({ phase: 'address', message: 'Delivery address?' })
return
}
await book(orderId, context.resumeData.address)
return { confirmed: true }
},
})

resumeSchema becomes the form the caller fills in, so it must describe a flat object of primitives (strings, numbers, booleans, enums). A caller that declines or cancels the form ends the call with an error, and the tool doesn't run again. A tool can suspend more than once by changing the phase in its payload.

Continuation state
Direct link to Continuation state

The continuation travels as an opaque requestState string that the client echoes back with its answer. The server signs it with requestState.key and rejects a tampered, expired or foreign state (a different tool, different arguments or a different caller) before your handler runs. The caller is the token subject when the authorization layer provides one, otherwise the id of the user mapAuthInfoToUser returns, otherwise the bearer token itself, so two users behind the same OAuth client can't resume each other's rounds.

On a server without authorization every caller shares one anonymous principal, so a requestState behaves like a bearer credential until its ttlSeconds expire: anyone who obtains it can answer the round. Put tools whose suspensions carry authority (writes, purchases, account changes) behind authorization.

The payload is signed, not encrypted, so keep it small and non-secret (IDs and phase, not confidential data). Set requestState.key from the environment so every instance that may answer a continuation shares the key. Without it the server generates a key per process and continuations only succeed on that process.

const server = new MCPServer({
id: 'booking',
name: 'Booking',
version: '1.0.0',
tools: { bookDelivery },
requestState: { key: process.env.MCP_REQUEST_STATE_KEY!, ttlSeconds: 600 },
})

Resource and prompt callbacks can suspend the same way. See Resource handling and Prompt handling.

Methods
Direct link to Methods

These are the functions you can call on an MCPServer instance to control its behavior and get information.

startStdio()
Direct link to startstdio

Use this method to start the server so it communicates using standard input and output (stdio). This is typical when running the server as a command-line program.

async startStdio(): Promise<void>

Here's how you would start the server using stdio:

const server = new MCPServer({
id: 'my-server',
name: 'My Server',
version: '1.0.0',
tools: {/* ... */},
})
await server.startStdio()

startHTTP()
Direct link to starthttp

This method helps you integrate the MCP server with an existing web server to use Streamable HTTP for communication. You'll call this from your web server's code when it receives HTTP requests.

async startHTTP({
url,
httpPath,
req,
res,
options,
}: {
url: URL;
httpPath: string;
req: http.IncomingMessage;
res: http.ServerResponse<http.IncomingMessage>;
options?: MCPServerHTTPRequestOptions;
}): Promise<void>

Every request is self-contained: there is no session to create or resume, so options only carries request guards.

OptionBehavior
enableDnsRebindingProtectionWhen true, the Host header is checked against allowedHosts and the Origin header against allowedOrigins before the request is handled. A check only runs when its list is non-empty. A rejected request receives a 403 JSON-RPC error.
allowedHostsHostnames accepted in the Host header. Matching is port-agnostic: localhost:3000 in the list allows any port on localhost. Write IPv6 addresses with brackets ([::1]).
allowedOriginsOrigins accepted in the Origin header. Only the hostname is compared, so https://app.example.com:8443 allows every scheme and port on app.example.com. Requests without an Origin header pass because non-browser MCP clients don't send one.

Omit options when you don't need request guards.

Here's an example of how you might use startHTTP within an HTTP server request handler. In this example an MCP client could connect to your MCP server at http://localhost:1234/mcp:

import http from 'http'

const httpServer = http.createServer(async (req, res) => {
await server.startHTTP({
url: new URL(req.url || '', 'http://localhost:1234'),
httpPath: `/mcp`,
req,
res,
})
})

httpServer.listen(PORT, () => {
console.log(`HTTP server listening on port ${PORT}`)
})

Because every request is self-contained, nothing needs to persist between invocations, so startHTTP works in serverless environments (Supabase Edge Functions, Cloudflare Workers, Vercel Edge, AWS Lambda, Deno Deploy). The method takes Node-style http.IncomingMessage and http.ServerResponse objects, so a Fetch-based runtime has to convert its Request with an adapter such as fetch-to-node and turn the result back into a Response:

// Supabase Edge Function example
import { serve } from 'https://deno.land/std@0.168.0/http/server.ts'
import { MCPServer } from '@mastra/mcp'
// Note: You will need to convert req/res format from Deno to Node
import { toReqRes, toFetchResponse } from 'fetch-to-node'

const server = new MCPServer({
id: 'my-serverless-mcp',
name: 'My Serverless MCP',
version: '1.0.0',
tools: {/* your tools */},
})

serve(async req => {
const url = new URL(req.url)

if (url.pathname === '/mcp') {
// Convert Deno Request to Node.js-compatible format
const { req: nodeReq, res: nodeRes } = toReqRes(req)

await server.startHTTP({ url, httpPath: '/mcp', req: nodeReq, res: nodeRes })

return toFetchResponse(nodeRes)
}

return new Response('Not found', { status: 404 })
})

Request-scoped features (input_required rounds, progress, per-request logs) stream inside the request that triggered them. Notifications that outlive a request (resources.notifyUpdated(), prompts.notifyListChanged(), toolActions.notifyListChanged()) are delivered on the subscriptions/listen stream a client keeps open, so they only reach clients while that stream is served by a running instance.

Here are the details for the values needed by the startHTTP method:

url:

URL
The web address the user is requesting.

httpPath:

string
The specific part of the URL where the MCP server will handle HTTP requests (e.g., '/mcp').

req:

http.IncomingMessage
The incoming request object from your web server.

res:

http.ServerResponse
The response object from your web server, used to send data back.

options?:

MCPServerHTTPRequestOptions
Optional request guards. See the options table above for more details.

close()
Direct link to close

This method closes the server and releases all resources.

async close(): Promise<void>

getServerInfo()
Direct link to getserverinfo

The method returns the server's basic information.

getServerInfo(): ServerInfo

getServerDetail()
Direct link to getserverdetail

The method returns details about the server's information.

getServerDetail(): ServerDetailInfo

getToolListInfo()
Direct link to gettoollistinfo

The method returns the tools that were set up when you created the server. It's a read-only list, useful for debugging purposes.

getToolListInfo(): { tools: ToolInfo[] }

getToolInfo()
Direct link to gettoolinfo

The method returns details about a specific tool.

getToolInfo(toolId: string): ToolInfo | undefined

executeTool()
Direct link to executetool

Runs a tool without a protocol client, which is how the Mastra REST route POST /api/mcp/:serverId/tools/:toolId/execute and Studio invoke tools. See authentication for registered MCP routes and the MCP routes reference.

async executeTool(
toolId: string,
args: unknown,
executionContext?: {
messages?: any[];
toolCallId?: string;
requestContext?: RequestContext;
resumeData?: unknown;
suspendPayload?: unknown;
},
): Promise<MCPToolExecutionResultV2>

The result is { status: 'completed', output } for a finished call, or { status: 'suspended', suspendPayload, resumeSchema } when the tool asked for input. Continue by calling again with the same args plus resumeData and the echoed suspendPayload. A tool that throws, or input or resume data that fails its schema, rejects the promise.

const result = await server.executeTool('bookDelivery', { orderId })
if (result.status === 'suspended') {
const answer = await askUser(result.suspendPayload, result.resumeSchema)
await server.executeTool(
'bookDelivery',
{ orderId },
{ resumeData: answer, suspendPayload: result.suspendPayload },
)
}

toolId:

string
The ID/name of the tool to execute.

args:

unknown
The arguments to pass to the tool's execute function, validated against its input schema.

executionContext?:

object
Optional context for the tool execution. Pass resumeData and suspendPayload to continue a suspended tool.

tools()
Direct link to tools

Returns the registered tool registry, keyed by tool ID.

tools(): Readonly<Record<string, ConvertedTool>>

Resource handling
Direct link to Resource handling

What are MCP Resources?
Direct link to What are MCP Resources?

MCP resources expose server data that clients can read and use as context for LLM interactions. Examples include:

  • File contents
  • Database records
  • API responses
  • Live system data
  • Screenshots and images
  • Log files

Resources are identified by unique URIs (e.g., file:///home/user/documents/report.pdf, postgres://database/customers/schema) and can contain either text (UTF-8 encoded) or binary data (base64 encoded).

Clients can discover resources through:

  1. Direct resources: Servers expose a list of concrete resources via a resources/list endpoint.
  2. Resource templates: For runtime-defined resources, servers can expose URI templates (RFC 6570) that clients use to construct resource URIs.

To read a resource, clients make a resources/read request with the URI. Servers can also notify clients about changes to the resource list (notifications/resources/list_changed) or updates to specific resource content (notifications/resources/updated) if a client is listening for that resource.

For more detailed information, refer to the official MCP documentation on Resources.

MCPServerResources Type
Direct link to mcpserverresources-type

The resources option takes an object of type MCPServerResources. This type defines the callbacks your server will use to handle resource requests:

export type MCPServerResources = {
// Callback to list available resources
listResources: (params: {
extra: MCPRequestHandlerExtra
requestContext: RequestContext
}) => Promise<Resource[]>

// Callback to get the content of a specific resource
getResourceContent: (
params: { uri: string } & MCPServerRequest,
) => Promise<MCPServerResourceContent | MCPServerResourceContent[] | void>

// Optional callback to list available resource templates
resourceTemplates?: (params: {
extra: MCPRequestHandlerExtra
requestContext: RequestContext
}) => Promise<ResourceTemplate[]>

// Shape of the answer a suspended getResourceContent expects
resumeSchema?: StandardSchemaWithJSON
}

export type MCPServerResourceContent = { text?: string } | { blob?: string }

Every callback receives extra, the same protocol context tools see as context.mcp.extra (cancellation signal, requestId, authInfo, _meta), and requestContext, the trusted application context that carries authInfo and the user mapped by mapAuthInfoToUser. Use them to scope what a caller can list and read.

getResourceContent also receives suspend, resumeData and suspendPayload (the MCPServerRequest members). Declare resumeSchema to make suspend usable. It works exactly as it does for tools.

Example:

import { MCPServer } from '@mastra/mcp'
import type {
MCPServerResourceContent,
MCPServerResources,
Resource,
ResourceTemplate,
} from '@mastra/mcp'

// Resources/resource templates will generally be dynamically fetched.
const myResources: Resource[] = [
{ uri: 'file://data/123.txt', name: 'Data File', mimeType: 'text/plain' },
]

const myResourceContents: Record<string, MCPServerResourceContent> = {
'file://data.txt/123': { text: 'This is the content of the data file.' },
}

const myResourceTemplates: ResourceTemplate[] = [
{
uriTemplate: 'file://data/{id}',
name: 'Data File',
description: 'A file containing data.',
mimeType: 'text/plain',
},
]

const myResourceHandlers: MCPServerResources = {
listResources: async () => myResources,
getResourceContent: async ({ uri, extra }) => {
if (myResourceContents[uri]) {
return myResourceContents[uri]
}
throw new Error(`Resource content not found for ${uri}`)
},
resourceTemplates: async () => myResourceTemplates,
}

const serverWithResources = new MCPServer({
id: 'resourceful-server',
name: 'Resourceful Server',
version: '1.0.0',
tools: {/* ... your tools ... */},
resources: myResourceHandlers,
})

Notifying Clients of Resource Changes
Direct link to Notifying Clients of Resource Changes

If the available resources or their content change, your server can notify clients that are listening for the specific resource.

server.resources.notifyUpdated({ uri: string })
Direct link to serverresourcesnotifyupdated-uri-string-

Call this method when the content of a specific resource (identified by its uri) has been updated. Clients subscribed to this URI receive a notifications/resources/updated message on their subscriptions/listen stream.

async server.resources.notifyUpdated({ uri: string }): Promise<void>

Example:

// After updating the content of 'file://data.txt'
await serverWithResources.resources.notifyUpdated({ uri: 'file://data.txt' })

server.resources.notifyListChanged()
Direct link to serverresourcesnotifylistchanged

Call this method when the list of available resources has changed (e.g., a resource was added or removed). This will send a notifications/resources/list_changed message to listening clients, prompting them to re-fetch the list of resources.

async server.resources.notifyListChanged(): Promise<void>

Example:

// After adding a new resource to the list managed by 'myResourceHandlers.listResources'
await serverWithResources.resources.notifyListChanged()

Prompt handling
Direct link to Prompt handling

What are MCP Prompts?
Direct link to What are MCP Prompts?

Prompts are reusable templates or workflows that MCP servers expose to clients. They can accept arguments and include resource context. They standardize LLM interactions.

Prompts are identified by a unique name and can be runtime-defined or static.

MCPServerPrompts Type
Direct link to mcpserverprompts-type

The prompts option takes an object of type MCPServerPrompts. This type defines the callbacks your server will use to handle prompt requests:

export type MCPServerPrompts = {
// Callback to list available prompts
listPrompts: (params: {
extra: MCPRequestHandlerExtra
requestContext: RequestContext
}) => Promise<Prompt[]>

// Callback to get the messages for a specific prompt
getPromptMessages?: (
params: { name: string; args?: Record<string, unknown> } & MCPServerRequest,
) => Promise<PromptMessage[] | void>

// Shape of the answer a suspended getPromptMessages expects
resumeSchema?: StandardSchemaWithJSON
}

The callbacks receive the same extra and requestContext as resource callbacks, and getPromptMessages can suspend in the same way when resumeSchema is declared. The server validates required prompt arguments before calling getPromptMessages.

Example:

import { MCPServer } from '@mastra/mcp'
import type { Prompt, PromptMessage, MCPServerPrompts } from '@mastra/mcp'

const prompts: Prompt[] = [
{
name: 'analyze-code',
description: 'Analyze code for improvements',
arguments: [{ name: 'code', description: 'The code to analyze', required: true }],
},
]

const myPromptHandlers: MCPServerPrompts = {
listPrompts: async () => prompts,
getPromptMessages: async ({ name, args }) => {
if (name === 'analyze-code') {
return [
{
role: 'user',
content: {
type: 'text',
text: `Analyze this code: ${args?.code}`,
},
},
]
}
throw new Error('Prompt not found')
},
}

const serverWithPrompts = new MCPServer({
id: 'prompt-server',
name: 'Prompt Server',
version: '1.0.0',
tools: {/* ... your tools ... */},
prompts: myPromptHandlers,
})

Notifying Clients of Prompt Changes
Direct link to Notifying Clients of Prompt Changes

If the available prompts change, your server can notify listening clients.

server.prompts.notifyListChanged()
Direct link to serverpromptsnotifylistchanged

Call this method when the list of available prompts has changed (e.g., a prompt was added or removed). This will send a notifications/prompts/list_changed message to listening clients, prompting them to re-fetch the list of prompts.

await serverWithPrompts.prompts.notifyListChanged()

Best practices for Prompt Handling
Direct link to Best practices for Prompt Handling

  • Use clear, descriptive prompt names and descriptions.
  • Validate all required arguments in getPromptMessages.
  • Return an error for unknown prompts or missing required arguments.
  • Notify clients whenever prompts change.

Dynamic tool management
Direct link to Dynamic tool management

Add and remove tools on a running server. Connected clients are notified with notifications/tools/list_changed.

The property is toolActions because tools() is the method that returns the registered tool registry.

toolActions.add(tools)
Direct link to toolactionsaddtools

Registers new tools on the running server and notifies connected clients. Tools are keyed by their record key, the same as tools passed to the constructor. Adding a tool under an existing key replaces it.

async server.toolActions.add(tools: ToolsInput): Promise<void>

Example:

import { createTool } from '@mastra/core/tools'
import { z } from 'zod'

const searchTool = createTool({
id: 'search',
description: 'Searches the knowledge base.',
inputSchema: z.object({ query: z.string() }),
execute: async ({ query }) => ({ results: [] }),
})

await server.toolActions.add({ searchTool })

toolActions.remove(toolIds)
Direct link to toolactionsremovetoolids

Removes tools from the running server by tool ID and notifies connected clients. Unknown tool IDs are ignored. A notification is sent only when at least one tool is removed.

async server.toolActions.remove(toolIds: string[]): Promise<void>

Example:

await server.toolActions.remove(['searchTool'])

toolActions.notifyListChanged()
Direct link to toolactionsnotifylistchanged

Sends a notifications/tools/list_changed message to connected clients without modifying the tool registry. Call this when tool availability changes through other means (for example, authorization changes).

async server.toolActions.notifyListChanged(): Promise<void>

Mastra registry synchronization
Direct link to Mastra registry synchronization

When the server is registered with a Mastra instance, toolActions.add() and toolActions.remove() also update the Mastra instance's tool registry, matching the automatic tool registration that happens at startup. Added tools become available through mastra.listTools() (keyed by the tool's intrinsic id when present), and removed tools are deleted from the registry.

Logging
Direct link to Logging

Tools send structured log messages to the calling client with notifications/message. Delivery is opted into per request: the client attaches the io.modelcontextprotocol/logLevel metadata key to its request, and the server delivers messages at or above that severity (following RFC 5424 ordering) for that request only. Without the opt-in nothing is delivered. A later round of the same tool call is a new request and must opt in again. The Mastra MCPClient sends the key on every request when enableServerLogs is on.

The Mastra logger and observability are unaffected: context.mcp.log() only controls what the MCP client receives.

context.mcp.log()
Direct link to contextmcplog

Inside a tool's execute function, use context.mcp.log() to send a log message to the client that called the tool.

async context.mcp.log(
level: LoggingLevel,
message: string,
data?: Record<string, unknown>
): Promise<void>

Example:

execute: async ({ location }, context) => {
await context.mcp?.log?.('debug', 'Fetching weather', { location })
const weather = await fetchWeather(location)
await context.mcp?.log?.('info', 'Weather fetched')
return weather
}

Progress notifications
Direct link to Progress notifications

Long-running tools can report progress to the calling client with notifications/progress. Progress is only sent when the caller requested progress tracking by including a progressToken in the request's _meta (the Mastra MCPClient does this when enableProgressTracking is set). When no token was sent, context.mcp.progress() is a no-op.

context.mcp.progress()
Direct link to contextmcpprogress

async context.mcp.progress(params: {
progress: number;
total?: number;
message?: string;
}): Promise<void>

Example:

execute: async ({ items }, context) => {
for (const [index, item] of items.entries()) {
await processItem(item)
await context.mcp?.progress?.({
progress: index + 1,
total: items.length,
message: `Processed ${item.name}`,
})
}
return { done: true }
}

Tracing
Direct link to Tracing

When the server is registered on a Mastra instance that has observability configured, every request it handles produces an MCP_SERVER_REQUEST root span: tools/list, tools/call, resources/*, and prompts/*. executeTool() produces the same span, so the Studio MCP server page is traced like an MCP client.

The span is named after the method and target (for example tools/call lookupOrder). It stores the request params as input and the response as output, and records the server name and version, the negotiated protocol version, and the client name and version when the client reported them. A tools/call that returns isError: true fails the span. Agents and workflows exposed as tools attach their AGENT_RUN and WORKFLOW_RUN spans under it. A served tool doesn't get its own TOOL_CALL span. Tools an agent calls inside the request still do.

A standalone MCPServer with no mastra instance produces no spans.

Linking to the caller's trace
Direct link to Linking to the caller's trace

A request that carries a W3C traceparent keeps its own trace, root span, and trace summary. Its request span links to the caller's span, so you can follow a tool call from the caller's trace to the request it started.

With a tracing bridge, the request span starts under the span that's active when the request arrives. If HTTP instrumentation on the server continues the caller's trace from the traceparent header, the request joins the caller's trace instead of starting its own. Its link then points to a span in the same trace.

The server reads traceparent and tracestate from two places, in this order:

  1. The request _meta. MCPClient sends traceparent there for every traced tool call an agent or workflow makes, over HTTP and stdio.
  2. The HTTP headers. OpenTelemetry HTTP instrumentation in a non-Mastra caller sends them there.

The request span also records a valid traceparent as the callerTraceparent attribute, and the tracestate sent with it as callerTracestate, capped at 512 characters. Exporters without span link support still carry these attributes. A request without a valid traceparent gets no link and no caller attributes. The trace fields are left out of the span input.

The link also works in the other direction. When a tools/call request carried a traceparent, the reply's _meta.mastra.traceparent names the request span, and MCPClient adds a link from its tool call span to it. Callers that sent no trace context get nothing back.

Mastra storage saves the links with the spans. The OpenTelemetry exporter, OtelBridge, DatadogBridge, DatadogExporter, and SentryExporter send them as span links. DatadogExporter and SentryExporter give spans their own IDs, so a link between two Mastra spans doesn't resolve in those backends.

Notification delivery
Direct link to Notification delivery

Request-scoped notifications (context.mcp.log(), context.mcp.progress()) stream inside the request that triggered them, so they reach exactly the caller. Notifications that outlive a request (resources.notifyListChanged(), prompts.notifyListChanged(), toolActions.notifyListChanged(), resources.notifyUpdated()) are delivered on the subscriptions/listen stream each interested client keeps open. resources.notifyUpdated() only reaches clients whose stream includes that resource URI. List-changed notifications reach every client listening for that list. Over stdio the connection itself carries the stream.

Examples
Direct link to Examples

For a practical example of packaging a stdio server, see Publish a stdio server package.

The example at the beginning of this page also demonstrates how to instantiate MCPServer with both tools and agents.

OAuth protection
Direct link to OAuth protection

To protect your MCP server with OAuth authentication per the MCP Authorization specification, use the createOAuthMiddleware function:

import http from 'node:http'
import { MCPServer, createOAuthMiddleware, createStaticTokenValidator } from '@mastra/mcp'

const mcpServer = new MCPServer({
id: 'protected-server',
name: 'Protected MCP Server',
version: '1.0.0',
tools: {/* your tools */},
})

// Create OAuth middleware
const oauthMiddleware = createOAuthMiddleware({
oauth: {
resource: 'https://mcp.example.com/mcp',
authorizationServers: ['https://auth.example.com'],
scopesSupported: ['mcp:read', 'mcp:write'],
resourceName: 'My Protected MCP Server',
validateToken: createStaticTokenValidator(['allowed-token-1']),
},
mcpPath: '/mcp',
})

// Create HTTP server with OAuth protection
const httpServer = http.createServer(async (req, res) => {
const url = new URL(req.url || '', 'https://mcp.example.com')

// Apply OAuth middleware first
const result = await oauthMiddleware(req, res, url)
if (!result.proceed) return // Middleware handled response (401, metadata, etc.)

// Token is valid, proceed to MCP handler
await mcpServer.startHTTP({ url, httpPath: '/mcp', req, res })
})

httpServer.listen(3000)

The middleware automatically:

  • Serves Protected Resource Metadata at /.well-known/oauth-protected-resource (RFC 9728)
  • Returns 401 Unauthorized with proper WWW-Authenticate headers when authentication is required
  • Validates bearer tokens using your provided validator

Token Validation
Direct link to Token Validation

For production, use proper token validation:

import { createOAuthMiddleware, createIntrospectionValidator } from '@mastra/mcp'

// Option 1: Token introspection (RFC 7662)
const middleware = createOAuthMiddleware({
oauth: {
resource: 'https://mcp.example.com/mcp',
authorizationServers: ['https://auth.example.com'],
validateToken: createIntrospectionValidator('https://auth.example.com/oauth/introspect', {
clientId: 'mcp-server',
clientSecret: 'secret',
}),
},
})

// Option 2: Custom validation (JWT, database lookup, etc.)
const customMiddleware = createOAuthMiddleware({
oauth: {
resource: 'https://mcp.example.com/mcp',
authorizationServers: ['https://auth.example.com'],
validateToken: async (token, resource) => {
const decoded = await verifyJWT(token)
if (!decoded) {
return { valid: false, error: 'invalid_token' }
}
return {
valid: true,
scopes: decoded.scope?.split(' ') || [],
subject: decoded.sub,
}
},
},
})

Return subject from your validator when you can. The server binds input_required continuations to it, so users sharing one OAuth client never share a continuation.

OAuth Middleware Options
Direct link to OAuth Middleware Options

oauth.resource:

string
The canonical URL of your MCP server. This is returned in Protected Resource Metadata.

oauth.authorizationServers:

string[]
URLs of authorization servers that can issue tokens for this resource.

oauth.scopesSupported?:

string[]
= ['mcp:read', 'mcp:write']
Scopes supported by this MCP server.

oauth.resourceName?:

string
Human-readable name for this resource server.

oauth.validateToken?:

(token: string, resource: string) => Promise<TokenValidationResult>
Function to validate access tokens. If not provided, tokens are accepted without validation (NOT recommended for production).

mcpPath?:

string
= '/mcp'
Path where the MCP endpoint is served. Only requests to this path require authentication.

Authentication context
Direct link to Authentication context

Tools can access request metadata via context.mcp.extra when using the HTTP transport. You can pass authentication info and user context, as well as custom data from your HTTP middleware to your MCP tools.

How it works
Direct link to How it works

Whatever you set on req.auth in your HTTP middleware becomes available as context.mcp.extra.authInfo in your tools and as requestContext.get('authInfo') in resource and prompt callbacks:

req.auth = { ... } โ†’ context.mcp.extra.authInfo = { ... }

Map auth data for FGA
Direct link to Map auth data for FGA

When an MCPServer is registered on a Mastra instance with a fine-grained authorization (FGA) provider, Mastra checks requestContext.get('user') before listing or calling tools. The HTTP transport passes authenticated data as extra.authInfo, so use mapAuthInfoToUser to set the user shape expected by your FGA provider.

const server = new MCPServer({
id: 'my-server',
name: 'My Server',
version: '1.0.0',
tools: { getUserData },
mapAuthInfoToUser: ({ authInfo }) => {
const user = authInfo as {
extra?: {
userId?: string
organizationMembershipId?: string
}
}

if (!user.extra?.userId) {
return null
}

return {
id: user.extra.userId,
organizationMembershipId: user.extra.organizationMembershipId,
}
},
})

Scope MCP tool FGA separately
Direct link to Scope MCP tool FGA separately

Use fga.resourceMapping and fga.permissionMapping when MCP clients need a different authorization scope than internal agent or workflow tool execution. The override applies only to tools/list and tools/call checks for this MCP server.

import { MastraFGAPermissions } from '@mastra/core/auth/ee'

const server = new MCPServer({
id: 'my-server',
name: 'My Server',
version: '1.0.0',
tools: { getUserData },
mapAuthInfoToUser: ({ authInfo }) => {
const user = authInfo as {
extra?: {
userId?: string
organizationMembershipId?: string
}
}

if (!user.extra?.userId) {
return null
}

return {
id: user.extra.userId,
organizationMembershipId: user.extra.organizationMembershipId,
}
},
fga: {
resourceMapping: {
tool: {
fgaResourceType: 'user',
deriveId: ({ user }) => (user as { id: string }).id,
},
},
permissionMapping: {
[MastraFGAPermissions.TOOLS_EXECUTE]: 'read',
},
},
})

Setting Up Authentication Middleware
Direct link to Setting Up Authentication Middleware

To pass data to your tools, populate req.auth on the Node.js request object in your HTTP server middleware before calling server.startHTTP().

import express from 'express'

type MCPAuthenticatedRequest = express.Request & {
auth?: {
token: string
clientId: string
scopes: string[]
expiresAt?: number
extra?: Record<string, unknown>
}
}

const app = express()

// Auth middleware - set req.auth before the MCP handler
app.use('/mcp', async (req, res, next) => {
const authorization = req.headers.authorization

if (!authorization?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing bearer token' })
return
}

const token = authorization.slice('Bearer '.length)

try {
const user = await verifyToken(token)

// This entire object becomes context.mcp.extra.authInfo
const authenticatedRequest = req as MCPAuthenticatedRequest
authenticatedRequest.auth = {
token,
clientId: user.clientId,
scopes: user.scopes,
expiresAt: user.expiresAt,
extra: {
sub: user.userId,
email: user.email,
},
}
next()
} catch {
res.status(401).json({ error: 'Invalid or expired token' })
}
})

app.all('/mcp', async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`)
await server.startHTTP({ url, httpPath: '/mcp', req, res })
})

Set extra.sub (or extra.subject) to the user's identifier. The server uses it to bind input_required continuations to that user.

Accessing Auth Data in Tools
Direct link to Accessing Auth Data in Tools

The req.auth object is available as context.mcp.extra.authInfo in your tool's execute function:

execute: async (inputData, context) => {
// Access the auth data you set in middleware
const authInfo = context.mcp?.extra.authInfo

if (!authInfo?.extra?.sub) {
return { error: 'Authentication required' }
}

// Use the auth data
console.log('User ID:', authInfo.extra.sub)
console.log('Email:', authInfo.extra.email)

// Use a credential intended for the API being called, not the MCP token.
// See https://mastra.ai/docs/guides/mcp-authentication-authorization#access-upstream-services-safely
const credential = await apiCredentials.getForUser(authInfo.extra.sub as string)

const response = await fetch('/api/data', {
headers: { Authorization: `Bearer ${credential.token}` },
signal: context.mcp?.extra.signal,
})

return response.json()
}

Passing RequestContext through to agent
Direct link to passing-requestcontext-through-to-agent

context.requestContext already carries authInfo and the mapped user, so pass it straight through when a tool calls an agent:

execute: async (inputData, context) => {
const authInfo = context.mcp?.extra.authInfo

if (!authInfo?.extra?.sub) {
return { error: 'Authentication required' }
}

const agent = context.mastra?.getAgentById('some-agent-id')

if (!agent) {
return { error: "Agent 'some-agent-id' not found" }
}

const response = await agent.generate(prompt, { requestContext: context.requestContext })

return response.text
}

The extra Object
Direct link to the-extra-object

The full context.mcp.extra object contains:

PropertyDescription
authInfoWhatever you set on req.auth in your middleware
requestIdThe JSON-RPC id of the current request
signalAbortSignal for request cancellation
_metaRequest metadata sent by the client: W3C trace fields, the io.modelcontextprotocol/logLevel log-level opt-in, and progressToken
sendNotification, sendRequestDeprecated. The protocol has no server-initiated requests, so both throw with a message naming the replacement: context.mcp.log(), context.mcp.progress(), or context.suspend().

Complete Example
Direct link to Complete Example

Install jose to verify JSON Web Tokens (JWTs) against your identity provider's JSON Web Key Set (JWKS):

npm install jose

The following example validates the token's signature, issuer, audience, algorithm, expiration, and required claims before passing its user data to the tool:

import express from 'express'
import { createRemoteJWKSet, jwtVerify } from 'jose'
import { MCPServer } from '@mastra/mcp'
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'

type MCPAuthenticatedRequest = express.Request & {
auth?: {
token: string
clientId: string
scopes: string[]
expiresAt?: number
extra?: Record<string, unknown>
}
}

const issuer = process.env.JWT_ISSUER
const audience = process.env.JWT_AUDIENCE
const jwksUri = process.env.JWT_JWKS_URI

if (!issuer || !audience || !jwksUri) {
throw new Error('JWT_ISSUER, JWT_AUDIENCE, and JWT_JWKS_URI are required')
}

const jwks = createRemoteJWKSet(new URL(jwksUri))

const verifyToken = async (token: string) => {
const { payload } = await jwtVerify(token, jwks, {
issuer,
audience,
algorithms: ['RS256'],
requiredClaims: ['exp'],
})

const clientId =
typeof payload.client_id === 'string'
? payload.client_id
: typeof payload.azp === 'string'
? payload.azp
: undefined

if (!payload.sub || typeof payload.email !== 'string' || !clientId || !payload.exp) {
throw new Error('Token must contain sub, email, exp, and client_id or azp claims')
}

return {
userId: payload.sub,
clientId,
email: payload.email,
expiresAt: payload.exp,
scopes: typeof payload.scope === 'string' ? payload.scope.split(' ') : [],
}
}

// 1. Define your tool that uses auth context
const getUserData = createTool({
id: 'get-user-data',
description: 'Fetches data for the authenticated user',
inputSchema: z.object({}),
execute: async (inputData, context) => {
const authInfo = context.mcp?.extra.authInfo

if (!authInfo?.extra?.sub) {
return { error: 'Authentication required' }
}

// Access the data you set in middleware
return {
userId: authInfo.extra.sub,
email: authInfo.extra.email,
}
},
})

// 2. Create the MCP server with your tools
const server = new MCPServer({
id: 'my-server',
name: 'My Server',
version: '1.0.0',
tools: { getUserData },
})

// 3. Set up Express with auth middleware
const app = express()

app.use('/mcp', async (req, res, next) => {
const authorization = req.headers.authorization

if (!authorization?.startsWith('Bearer ')) {
res.status(401).json({ error: 'Missing bearer token' })
return
}

const token = authorization.slice('Bearer '.length)

try {
const user = await verifyToken(token)

// This entire object becomes context.mcp.extra.authInfo
const authenticatedRequest = req as MCPAuthenticatedRequest
authenticatedRequest.auth = {
token,
clientId: user.clientId,
scopes: user.scopes,
expiresAt: user.expiresAt,
extra: {
sub: user.userId,
email: user.email,
},
}
next()
} catch {
res.status(401).json({ error: 'Invalid or expired token' })
}
})

app.all('/mcp', async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host}`)
await server.startHTTP({ url, httpPath: '/mcp', req, res })
})

app.listen(3000)

MCP Apps (appResources)
Direct link to mcp-apps-appresources

The appResources option lets you serve interactive HTML UIs from your MCP server via the MCP Apps extension. Each entry maps a ui:// URI to an HTML app that renders in a sandboxed iframe in Mastra Studio.

AppResources type
Direct link to appresources-type

Key (URI):

string
A ui:// URI that identifies the app resource (e.g., ui://calculator/main).

Each value is an AppResource object:

name:

string
Display name for the UI resource.

description?:

string
Optional description of the UI resource.

html?:

string
Inline HTML content for the UI. Provide either html or htmlPath.

htmlPath?:

string
Path to an HTML file. Resolved at server startup. Provide either html or htmlPath.

meta?:

McpUiResourceMeta
UI resource metadata (CSP, permissions, rendering preferences) from the official ext-apps SDK.

Example
Direct link to Example

import { MCPServer } from '@mastra/mcp'
import { createTool } from '@mastra/core/tools'
import { z } from 'zod'

const calculatorTool = createTool({
id: 'calculatorWithUI',
description: 'An interactive calculator',
inputSchema: z.object({
num1: z.number(),
num2: z.number(),
operation: z.enum(['add', 'subtract']),
}),
mcp: {
_meta: { ui: { resourceUri: 'ui://calculator/main' } },
},
execute: async ({ num1, num2, operation }) => {
const result = operation === 'add' ? num1 + num2 : num1 - num2
return {
content: [{ type: 'text', text: 'An interactive calculator is displayed.' }],
structuredContent: { result },
}
},
})

const server = new MCPServer({
id: 'app-server',
name: 'App Server',
version: '1.0.0',
tools: { calculatorTool },
appResources: {
'ui://calculator/main': {
name: 'Interactive Calculator',
html: '<html><body><h2>Calculator</h2>...</body></html>',
},
},
})

Link a tool to its app resource by setting mcp._meta.ui.resourceUri in createTool() to the matching ui:// URI. The server normalizes this metadata for older hosts when listing tools, and returns the same link on successful tool call results. Visit MCP Apps for the full app bridge API and usage patterns.

Events (experimental)
Direct link to Events (experimental)

warning

Events implement SEP-3415, a draft MCP extension. The wire format and this API may change in any release.

For an end-to-end walkthrough, see MCP events.

The events option lets clients subscribe to things that happen on your server, such as a ticket being created or a build finishing. The server advertises the io.modelcontextprotocol/events extension and answers events/list, events/poll and events/stream over both stdio and Streamable HTTP. With eventsOptions.webhook set, it also answers events/subscribe and events/unsubscribe and POSTs occurrences to client webhook URLs.

Declare each event type with defineEvent() and publish occurrences with server.events.emit():

import { MCPServer, defineEvent } from '@mastra/mcp'
import { z } from 'zod'

const server = new MCPServer({
id: 'tickets',
name: 'Tickets',
version: '1.0.0',
tools: {},
events: {
'ticket.created': defineEvent({
description: 'A ticket was created',
inputSchema: z.object({ project: z.string().optional() }),
payloadSchema: z.object({ id: z.string(), project: z.string() }),
match: (args, data) => !args.project || data.project === args.project,
}),
},
})

await server.events.emit('ticket.created', { id: 't-1', project: 'web' })

A client that subscribed with { project: 'web' } receives this occurrence. A client that subscribed with { project: 'api' } doesn't.

match filters on the arguments the client chooses, so it doesn't control who can subscribe. To restrict callers, add authorize. It receives the validated arguments and the caller's requestContext, which includes user when you set mapAuthInfoToUser:

const ticketCreated = defineEvent({
inputSchema: z.object({ project: z.string() }),
payloadSchema: z.object({ id: z.string(), project: z.string() }),
match: (args, data) => data.project === args.project,
authorize: ({ arguments: args, requestContext }) => {
const user = requestContext.get('user') as { projects: string[] } | undefined
return user?.projects.includes(args.project) ?? false
},
})

authorize runs on every events/poll, every events/stream open, and every webhook events/subscribe, including refreshes. An open stream isn't checked again until it reconnects, and a webhook subscription until its next refresh. A caller who loses access keeps receiving occurrences until then. Use authorize to restrict events because FGA doesn't apply to them.

defineEvent() options
Direct link to defineevent-options

description?:

string
Description returned by events/list.

inputSchema?:

StandardSchemaWithJSON
Schema for the subscription arguments. Advertised as JSON Schema. Arguments that fail validation are rejected with error -32602, and match and poll receive the parsed value.

payloadSchema?:

StandardSchemaWithJSON
Schema for the event data. Advertised as JSON Schema and enforced by server.events.emit().

match?:

(args, data) => boolean
Decides whether an emitted event reaches a subscription with these arguments. Applies to push streams and to polls served from the buffer. Defaults to delivering every event.

authorize?:

(context) => boolean | Promise<boolean>
Decides whether the caller may receive this type with these arguments. Receives arguments, requestContext and delivery ('poll', 'push' or 'webhook'). Runs on every events/poll, before a poll source is called, on every events/stream open, and on every webhook events/subscribe, including refreshes. Returning false rejects the request with -32024. A refresh that is denied also removes the webhook subscription. If it throws, the client gets a generic -32603 error and the details are logged on the server. Defaults to allowing every caller.

poll?:

(context) => Promise<MCPServerEventPollResult>
Pull source for events/poll. Receives arguments, cursor (null means start from now), maxEvents, maxAgeMs, requestContext and signal. Returns { events, cursor, hasMore?, nextPollMs?, truncated? }. Each event needs data and a stable eventId (clients dedupe by it, so return the same id whenever the same occurrence is polled); when cursor is null, resolve the starting position once, and return the id of the last occurrence, or that starting position when the result is empty; a missing timestamp defaults to now. If poll or match throws, the client gets a generic -32603 error and the details are logged on the server.

delivery?:

Array<'poll' | 'push' | 'webhook'>
Restricts the advertised delivery modes to this list. It only narrows the modes from the table below, and a definition left with no mode throws when the server is constructed.

bufferSize?:

number
= 1000
Number of emitted events kept in memory for replay and buffered polls.

pollIntervalMs?:

number
nextPollMs suggested to clients that poll the buffer.

Delivery modes
Direct link to Delivery modes

How a type is served depends on where its events come from:

DefinitionAdvertised deliverySource
No poll['push', 'poll'], plus 'webhook' when eventsOptions.webhook is setserver.events.emit(). Polls read the in-memory buffer.
poll['poll']Your poll function, called for each events/poll.

A type with a poll source can't also be pushed: delivery can't add 'push' to it, and server.events.emit() throws for it. The advertised modes never change after the server starts, because clients only learn them from events/list. To push events, define a type without poll and emit to it.

A request for a mode the type doesn't advertise fails with -32026. An unknown event name fails with -32023.

Push streams
Direct link to Push streams

Each events/stream request is one subscription. The server sends notifications/events/active first, then one notifications/events/event per matching occurrence. Every notification carries the request id as _meta["io.modelcontextprotocol/subscriptionId"]. When the request includes a cursor, buffered events after it are replayed before live ones. If that cursor is older than the buffer, active reports truncated: true.

When no other message has been sent for heartbeatIntervalMs, the stream sends notifications/events/heartbeat with the current cursor. Cancelling the request, or calling server.close(), ends the stream and releases its listener and timer.

eventsOptions
Direct link to eventsoptions

heartbeatIntervalMs?:

number
= 30000
Longest gap between messages on a push stream.

listPageSize?:

number
= 100
Number of event types per events/list page.

maxStreams?:

number
= 1024
Most push streams open at once across all clients. Further events/stream requests are rejected with -32025 until one closes. A Mastra client retries the rejection when reconnecting an established subscription; a new subscribe() call rejects with it.

webhook?:

MCPServerWebhookOptions
Enables webhook delivery for event types without a poll source. Without it, the server doesn't advertise webhook or answer events/subscribe.

Webhook delivery
Direct link to Webhook delivery

With eventsOptions.webhook set, a client can register a callback URL with events/subscribe, and the server POSTs each matching occurrence to it, signed with the client's secret using Standard Webhooks HMAC-SHA256.

const server = new MCPServer({
id: 'tickets',
name: 'Tickets',
version: '1.0.0',
tools: {},
events: {
'ticket.created': defineEvent({ payloadSchema: z.object({ id: z.string() }) }),
},
eventsOptions: {
webhook: { ttl: { defaultMs: 30 * 60_000 } },
},
})

The server enforces these rules:

  • Authentication: Webhook subscriptions need an authenticated caller. Without authInfo on the request, events/subscribe fails with -32024. A subscription is identified by the caller, the URL, the event name, and the arguments, so repeating events/subscribe refreshes the same subscription and returns the same id.
  • Authorization: authorize runs on each subscribe and each refresh. When it denies a refresh, the subscription is removed and no further occurrences are POSTed. A Mastra client can't tell this -32024 from the one for an unauthenticated caller, so in its default delivery mode it reports MCP_EVENTS_WEBHOOK_UNAVAILABLE, tries push, and then rejects with the same -32024.
  • Callback URLs: The URL must use https. Every address it resolves to must be publicly routable: loopback, private, link-local, and other special-purpose ranges are rejected with -32602 and data.reason. The check runs again before every delivery, and the request connects to the address that passed, to prevent DNS rebinding. Redirects are never followed.
  • Verification: Before the first delivery to a URL, the server POSTs a signed verification challenge, and the endpoint must echo it. A failed or unreachable endpoint fails events/subscribe with -32027 and a data.reason such as challenge_failed or timeout. The result is cached per caller and URL. URLs accepted by allowedUrls skip this step.
  • Lifetime: Each subscription expires at the returned refreshBefore time unless the client refreshes it. A requested ttlMs is clamped to the ttl bounds.
  • Retries: Each occurrence is retried with exponential backoff until it's acknowledged with a 2xx, retry.maxAttempts is reached, or retry.maxWindowMs has passed. A 410 or 413 response stops retries for that occurrence. The cursor in each body only advances past occurrences that are acknowledged or given up on.
  • Suspension: When most deliveries to a subscription fail over suspend.windowMs, the server pauses it and reports active: false in deliveryStatus on the next refresh. The refresh resumes delivery.
  • Errors: Clients only see a failure category such as timeout or http_5xx, never the endpoint's response body or headers.

ttl?:

{ defaultMs?: number; minMs?: number; maxMs?: number }
Subscription lifetime granted when the client omits ttlMs, and the bounds a requested ttlMs is clamped to. Defaults to 30 minutes, 60 seconds, and 24 hours.

allowNoExpiry?:

boolean
= false
Grants ttlMs: null requests a subscription that never expires. Otherwise they get ttl.maxMs. Only enable it with a durable store, because clients with no-expiry subscriptions don't need to refresh after a server restart.

store?:

WebhookSubscriptionStore
= InMemoryWebhookSubscriptionStore
Stores subscription records (identity, secret, expiry) with async get, set, delete, and list methods. The default keeps them in memory, so subscriptions are lost on restart until clients refresh.

url?:

{ allowPrivate?: boolean; allowInsecure?: boolean; lookup?: WebhookLookup }
Callback URL policy. allowPrivate accepts non-public addresses and allowInsecure accepts http. Use them for local development only. lookup replaces DNS resolution.

allowedUrls?:

(url: URL) => boolean
Returns true for URLs you trust, which then skip the verification challenge.

verificationTtlMs?:

number
= 86400000
How long a successful verification is remembered for a caller and URL.

timeoutMs?:

number
= 5000
Timeout for each delivery attempt.

retry?:

{ maxAttempts?: number; initialDelayMs?: number; maxDelayMs?: number; maxWindowMs?: number }
Retry policy for each occurrence. Defaults to 5 attempts, a 1 second initial delay, a 5 minute maximum delay, and a 15 minute window. Time spent suspended doesn't count against the window.

suspend?:

{ windowMs?: number; minAttempts?: number; failureRate?: number }
Suspends a subscription when at least failureRate of at least minAttempts attempts fail within windowMs. Defaults to 0.95, 100, and 60 minutes.

maxPending?:

number
= 1000
Undelivered occurrences kept per subscription. On overflow the oldest are dropped and the client receives a gap body, so it reports truncated: true.

maxSubscriptions?:

number
= 1000
Most webhook subscriptions this process keeps across all callers. New subscriptions past the limit are rejected with -32025, and a Mastra client falls back to push or poll unless it forced webhook delivery. Refreshing an existing subscription still works.

maxSubscriptionsPerPrincipal?:

number
= 100
Most webhook subscriptions one authenticated caller can hold.

secretGraceMs?:

number
= 300000
After a client rotates its secret, deliveries are signed with both secrets for this long.

transport?:

WebhookTransport
Replaces the HTTP client that sends deliveries, for example in tests.

server.events.emit(name, data, options?)
Direct link to servereventsemitname-data-options

Records an occurrence, delivers it to matching push streams and webhook subscriptions, and returns it with its eventId, timestamp and cursor. options.eventId and options.timestamp override the defaults (a random UUID and the current time). It throws when name isn't configured, when data fails payloadSchema, or when the type has a poll source.

server.events.list() returns the descriptors events/list serves.

Limits
Direct link to Limits

  • Emitted events stay in the process that emitted them. Push streams, webhook subscriptions, and buffered polls only see events emitted by the same MCPServer instance, so a serverless or multi-instance deployment needs a poll source backed by shared storage.
  • The buffer is in memory. A restarted server treats earlier cursors as a gap and reports truncated: true.
  • Webhook delivery state (pending retries, suspension, verification results) is in memory and rebuilt when clients refresh.
  • Event-list change announcements require the eventsListChanged filter on subscriptions/listen, which the MCP TypeScript SDK version used by @mastra/mcp doesn't carry. Until that support is available, the server neither advertises listChanged nor sends notifications/events/list_changed. Clients should re-run events/list to see changed event types.