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.

To expose every agent, workflow, and tool on your Mastra app without defining a server, use the built-in MCP server at /api/mcp instead. It builds its tool list per caller and runs each call through the matching HTTP route's auth and permission checks. Use MCPServer to publish a chosen set of tools and agents with your own prompts and resources, or to serve over stdio.

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.

toolResult?:

MCPServerToolResultOptions
Controls how completed tool calls become MCP results. modelOutput: true builds the result content from each tool's toModelOutput, so tools can return images, audio, and files. transform rewrites a result before it's sent. See Tool results for details.

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.

Tool results
Direct link to Tool results

By default, a tool's output is sent to the MCP client as a single text block. A string is sent as-is and any other value as JSON. A tool with an outputSchema also returns the value as structuredContent.

Use the toolResult option to send the model-facing output of a tool instead, or to rewrite results before they're sent.

src/mastra/mcp/server.ts
import { MCPServer } from '@mastra/mcp'
import { screenshotTool } from '../tools/screenshot'

const server = new MCPServer({
id: 'media-server',
name: 'Media Server',
version: '1.0.0',
tools: { screenshotTool },
toolResult: {
modelOutput: true,
},
})

modelOutput?:

boolean
= false
Builds the result content from the tool's `toModelOutput`, the same output an agent would send to its model. Tools without toModelOutput, and mappers that return undefined or an unrecognized shape, keep the default result.

transform?:

(context: { toolName: string; output: unknown; result: CallToolResult; requestContext: RequestContext }) => CallToolResult | undefined | Promise<CallToolResult | undefined>
Rewrites the result of every completed tool call, including ask_* agent tools and run_* workflow tools. output is the raw tool output and result is the result the server would send, after modelOutput mapping. Return undefined to keep result. It runs for direct calls and tool tasks, but not for thrown tool errors or workflow tasks.
warning

The default of modelOutput becomes true in the next major version of @mastra/mcp. If your MCP clients parse the raw JSON text of tools that define toModelOutput, set modelOutput: false now to keep the current behavior.

With modelOutput: true, the server maps toModelOutput values to MCP content:

toModelOutput valueMCP result
{ type: 'text', value }Text block
{ type: 'json', value }Text block with the JSON value
{ type: 'error-text' } or { type: 'error-json' }Text block with isError: true
content part textText block
content part media, image-data, or file-dataimage block for image/*, audio block for audio/*, otherwise an embedded resource with a blob
content part image-url or file-url with a data: URISame as media
content part image-url or file-url with a remote URLText block with the URL

For a tool with an outputSchema, the result keeps structuredContent and its JSON text block first, and the mapped blocks follow. If toModelOutput or transform throws, the client receives the tool error result.

Use transform to forward results that are already MCP-shaped, for example from tools proxied through an MCPClient:

src/mastra/mcp/proxy-server.ts
import { MCPClient, MCPServer, type MCPServerToolResultContext } from '@mastra/mcp'

const upstream = new MCPClient({
servers: { files: { url: new URL('https://files.example.com/mcp') } },
})

const server = new MCPServer({
id: 'proxy-server',
name: 'Proxy Server',
version: '1.0.0',
tools: await upstream.listTools(),
toolResult: {
// Unstructured upstream tools return the upstream MCP result as their output
transform: ({ output }) => {
const upstreamResult = output as MCPServerToolResultContext['result']
return Array.isArray(upstreamResult?.content) ? upstreamResult : undefined
},
},
})

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.

A resources/read request resolves its URI against listResources first. When no listed resource matches, the URI is matched against the caller's resourceTemplates, and getResourceContent receives the expanded URI. Optional expressions such as {?format} or {/section} may be left out of the URI. Variable values must be percent-encoded as RFC 6570 expansion produces them, so file://data?format=a/b doesn't match file://data{?format}. The response uses the mimeType and _meta of the matching resource or template. A URI that matches neither fails with Resource not found.

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/readme', name: 'Readme', mimeType: 'text/plain' },
]

const myResourceContents: Record<string, MCPServerResourceContent> = {
'file://data/readme': { text: 'Read this first.' },
// Not listed; readable as an expansion of the `file://data/{id}` template
'file://data/123': { text: 'This is the content of data file 123.' },
}

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.

Tasks (experimental)
Direct link to Tasks (experimental)

warning

Tasks implement SEP-2663, the io.modelcontextprotocol/tasks MCP extension. This API may change in any release.

The tasks option lets a tool, a workflow (run_*) or an agent (ask_*) answer tools/call with a task instead of a result. The client gets a task handle at once and polls tasks/get until the task finishes. The result outlives the request and isn't limited by HTTP timeouts. When at least one of them can answer with a task, the server advertises the extension and answers tasks/get, tasks/update and tasks/cancel over both stdio and Streamable HTTP. Without a tasks option, it does neither.

Only clients that declare the extension on a request get a task. Every other client gets a synchronous answer, unless the tool is listed in required.

import { MCPServer } from '@mastra/mcp'

const server = new MCPServer({
id: 'reports',
name: 'Reports',
version: '1.0.0',
tools: { buildReport, lookupCustomer },
tasks: {
tools: {
buildReport: 'always',
lookupCustomer: { mode: 'auto', syncWindowMs: 1000 },
},
},
})

Modes
Direct link to Modes

ModeClient declared the extensionClient didn't
'off'Synchronous answer.Synchronous answer.
'always'Task handle at once.Synchronous answer, or error -32021 when the tool is in required.
'auto'Synchronous answer if the tool finishes within syncWindowMs. Otherwise a task handle, and the same run continues.Synchronous answer, or error -32021 when the tool is in required.

The server creates the task before it answers, so a tasks/get sent right after the handle arrives succeeds.

tasks options
Direct link to tasks-options

default?:

'off' | 'auto' | 'always'
= 'off'
Mode for every tool, workflow and agent not listed in tools, workflows or agents, including tools added later with toolActions.add(). It covers run_* and ask_* tools too, so default: 'always' serves every workflow and agent as a task; list one as 'off' to keep it synchronous.

tools?:

Record<string, 'off' | 'auto' | 'always' | { mode; syncWindowMs?; ttlMs?; recover? }>
Mode per tool name. syncWindowMs (default 2000) is how long 'auto' waits for a synchronous answer. ttlMs and recover override tasks.ttlMs and tasks.recover for that tool. A name that isn't one of the server's tools throws when the server is created.

required?:

string[]
Tools that only run as tasks, by the name clients call: a tool's key, run_<workflow> or ask_<agent>. A client that didn't declare the extension gets error -32021 instead of a synchronous answer. Each name must be served as a task.

ttlMs?:

number | null
= 86400000
How long a task is kept after it is created, in milliseconds. A task still running when it expires is aborted and ends failed. With the in-memory store, null keeps tasks until the server closes, and finished results stay in memory until their TTL ends; maxRetainedTasksPerClient and maxRetainedTasks bound how many it holds. A task with a null TTL counts against them until the server closes. In Mastra storage, the server deletes the row of a finished task once its TTL ends, null keeps the row until you delete it, and a task still running after about 24.8 days (the longest timer Node.js supports) ends failed.

recover?:

boolean
= false
Whether a tool or agent task whose server stopped mid-run runs again from the start on another instance. Only applies to tasks kept in Mastra storage. Workflow tasks ignore it, see Workflow tasks. See Recovery after a crash.

store?:

MCPTaskStore
Where tasks are kept. By default, a server registered on a Mastra instance with background tasks keeps them in Mastra storage (see Tasks across instances); any other server keeps them in memory, in this process.

maxOpenTasksPerClient?:

number
= 100
Most open tasks (working or input_required) one caller can hold. See open task caps.

maxOpenTasks?:

number
= 1000
Most open tasks across all callers. See open task caps.

maxRetainedTasksPerClient?:

number
= 1000
Most tasks one caller can have in the store, open or finished, until each is forgotten. Only the in-memory store enforces it. See retained task caps.

maxRetainedTasks?:

number
= 10000
Most tasks in the store across all callers, open or finished. See retained task caps.

authorize?:

(context: { principal: string; task: MCPDetailedTask; requestContext: RequestContext }) => boolean | Promise<boolean>
Runs on every tasks/get, tasks/update and tasks/cancel, after the caller is matched to the task. Return false to refuse; the caller gets the same error as for an unknown task.

workflows?:

Record<string, 'off' | 'auto' | 'always' | { mode; syncWindowMs?; ttlMs? }>
Mode per workflow key, for its run_<key> tool. A key that isn't one of the server's workflows throws when the server is created. See Workflow tasks.

agents?:

Record<string, 'off' | 'auto' | 'always' | { mode; syncWindowMs?; ttlMs?; recover? }>
Mode per agent key, for its ask_<key> tool. A key that isn't one of the server's agents throws when the server is created. See Agent tasks.

Access to tasks
Direct link to Access to tasks

A task id is an unguessable bearer token, and each task is bound to the caller that created it. The caller is identified the same way as for continuation state: by the OAuth subject, the mapped user, or a hash of the bearer token. Another caller asking for the task, and a caller authorize refuses, both get error -32602, the same answer as for an id that doesn't exist. Clients can't list tasks over MCP.

Tasks in Studio
Direct link to Tasks in Studio

The Studio page of a server with tasks shows which tools run as tasks (always or auto, plus required and recover), and lists the server's tasks across all callers, newest first: their status, the client that sent them, open input requests, and when they expire. You can filter the list by status, tool or client, load older tasks, or paste the MCP task id a client shows to open that task. Opening a task shows its result or error. From there you can cancel it, or answer an elicitation it's waiting on. Cancelling and answering run the same code as tasks/cancel and tasks/update, but skip authorize.

  • Studio identifies a task by a hash of its id, and the caller by a hash of its identity. Neither can be used over MCP, and the raw values are never sent to Studio. A task id you paste is sent in the request body and hashed on the server.
  • The client is shown as its client id with the token's subject or user id. A caller identified only by its token is shown with a short prefix of the token's hash instead. The label is never a credential.
  • With an RBAC provider, the list and task lookup need mcp:read. Cancelling and answering need mcp:write, which the default admin role has and member and viewer don't. Without it, Studio shows the task without those actions.
  • Only Studio sessions without a resource scope get the list. MCP callers and Mastra resources are separate identities, so a session scoped to a resource gets no tasks and can't cancel or answer one.
  • The built-in task stores support the list. A custom store must also implement MCPTaskStoreAdmin (list, updateByKey, cancelByKey); otherwise Studio says the store can't be listed.

Open task caps
Direct link to Open task caps

A task counts as open while it's working or input_required. It stops counting when it completes, fails, is cancelled or expires. When a call would go over maxOpenTasksPerClient or maxOpenTasks, the server creates no task:

  • An 'always' tool doesn't run. The call answers with isError: true and a message such as Too many open tasks for this client (limit 100). Wait for one to finish or cancel one.
  • An 'auto' tool that's still running when its sync window ends keeps running under the request and answers synchronously, the same way it would without tasks. Cancelling the request still aborts it.
  • run_<workflow> and ask_<agent> tasks count against the same caps, and against the retained task caps. An 'always' workflow starts no run, and an 'always' agent doesn't run. An 'auto' workflow run that suspends can only be answered through a task, so at the cap the server cancels it and answers with isError: true.

Both values must be positive integers. The server passes them to store.create() as limits, and the store enforces them by throwing MCPTaskLimitError. A custom store that ignores limits doesn't cap open tasks.

Retained task caps
Direct link to Retained task caps

The in-memory store keeps a finished task's result in memory so the client can still read it, until the task's ttlMs ends plus one more minute. Finishing a task frees its open slot but not its memory, so a client that runs many short tasks in a row could otherwise grow memory without bound. maxRetainedTasksPerClient and maxRetainedTasks cap every task the store still holds, open or finished.

  • A call that would go over a retained cap gets the same answer as at an open cap. An 'always' tool answers with isError: true and a message such as Too many recent tasks for this client (limit 1000). Try again once older tasks expire. An 'auto' tool answers synchronously.
  • Tasks already created keep their full ttlMs. The store never drops a result early to make room. A slot frees when the store forgets a task.
  • Every open task is also retained. When you set only one of a pair, the other follows it: lowering maxRetainedTasksPerClient below 100 lowers the default maxOpenTasksPerClient to match, and raising maxOpenTasks above 10000 raises the default maxRetainedTasks. Setting both with the retained cap below the open cap throws when the server is created.

Only the in-memory store enforces retained caps. Tasks kept in Mastra storage (see Tasks across instances) keep finished results in the database, so retained caps don't apply to them. Open task caps still do. A custom store that keeps finished results elsewhere can ignore them too.

Asking for input inside a task
Direct link to Asking for input inside a task

A tool that calls suspend() inside a task moves it to input_required. Its input request is the same form a synchronous call would get, built from the tool's resumeSchema. When the client answers with tasks/update, the server runs execute again with resumeData, as for a synchronous call. Keep the work before suspend() safe to repeat.

A declined or cancelled input request completes the task with isError: true. Answers to keys the task didn't ask for are ignored.

What a task run receives
Direct link to What a task run receives

Task runs are rebuilt from what the task stored when it was created. This keeps a task store free to run them again after a restart. Every run of a task gets the stored request context and no context.mcp. The one exception is the first run of an 'auto' tool, which started under the request before the sync window elapsed.

  • The bearer token is removed from authInfo before the request context is stored. Everything else is stored unchanged, including authInfo.extra and the user returned by mapAuthInfoToUser. Don't put credentials there that the task store shouldn't keep.
  • Values are stored as JSON. A run reads back plain objects, so a user class instance arrives as a plain object with the same fields.
  • A key that can't be stored as JSON is left out, and the server logs a warning when it creates the task. If that key is user, authorization checks on later runs fail.
  • Read cancellation from context.abortSignal, because context.mcp isn't available.
  • Each run is authorized again against the stored request context, as each round of a synchronous call is.

Cancellation and errors
Direct link to Cancellation and errors

  • tasks/cancel aborts the tool's abortSignal and the task ends cancelled. Stopping is up to the tool. A result it returns afterwards is discarded.
  • A tool that throws completes the task with isError: true and the serialized error, as a synchronous call answers.
  • A task ends failed only when the server can't run it, for example when its TTL expires.

Workflow tasks
Direct link to Workflow tasks

A run_<key> task follows one workflow run. The run starts once, under the tools/call that creates the task, with a run id of its own. The task id is never the run id.

const server = new MCPServer({
id: 'reports',
name: 'Reports',
version: '1.0.0',
workflows: { nightly },
tasks: {
workflows: { nightly: { mode: 'auto', syncWindowMs: 1000 } },
},
})
  • A run that ends success or bailed completes the task with the same text a synchronous call answers: status, input, steps and result.
  • A run that ends failed, tripwire or canceled completes the task with isError: true and the error message. In 'auto' mode, a run that fails within syncWindowMs answers the same way, without a task. Only a client without the Tasks extension, or a workflow whose mode is 'off', gets the plain run_ answer, where a failed run comes back without isError.
  • In 'auto' mode, a run that suspends within syncWindowMs continues as a task, so it can ask for input.
  • Invalid arguments answer with isError: true before any run or task exists.
  • A step that suspends moves the task to input_required, with a form built from the step's resumeSchema and its suspend payload as the message. The server asks for one suspended step at a time. tasks/update resumes the run with the answer as resumeData. Resuming needs Mastra storage.
  • A run is resumed at most once per suspension. If it was already resumed, for example from Studio or by another instance, the answer is dropped and the task follows the run: when the run suspends again, even on the same step in a loop, the task asks again with a new input request. A resume from outside the task that reaches the run at the same moment as the task's own can still let a stale answer resume the next suspension of that same step.
  • A step whose resumeSchema can't be shown as a form, such as one with nested objects, leaves the task working. Resume the run another way, for example from Studio, and the task follows it. Until then the task reads the run's state every second and, on Mastra background tasks, holds a background task slot, as it does while it follows a run another instance executes.
  • Declining the input request, tasks/cancel, and TTL expiry cancel the run, including a run waiting for input, from any instance. A run executing on the instance that handles the cancel stops there. A run executing on another instance stops when that instance next renews its lease on the task, within about one lease.
  • A server that shuts down, or loses a task to another worker, stops following the run but leaves it running. Another instance picks the task up and follows the run to its end.
  • With a store passed in tasks, the server cancels the run only when the client declines. Have the store's cancel and expiry check the task's context with isWorkflowTaskContext() and call mastra.getWorkflow(context.workflowKey).createRun({ runId: context.runId }), then run.cancel() on it. In the process executing the run, that handle is the executing run, so cancel() stops it there.

When the server running a workflow task stops, Mastra's workflow recovery continues the run on the next instance that starts, and the task follows it there. This requires recovery.workflows to be 'auto', the default. With 'off', the task stays working until its TTL expires. The step that was running when the server stopped runs again, so keep steps safe to repeat. A task whose run was never saved, or whose follower stops a second time, ends failed.

Agent tasks
Direct link to Agent tasks

An ask_<key> task runs the agent's generate() as the task's work, like a tool task: tasks/cancel aborts the agent's abortSignal, an error completes the task with isError: true, and recover applies.

An agent that pauses for tool approval or other input completes the task with isError: true. Tasks can't answer an agent's approval requests yet.

Tasks across instances
Direct link to Tasks across instances

Register the server on a Mastra instance with background tasks enabled and its tasks are kept in Mastra storage. Any instance sharing that storage can answer tasks/get, tasks/update and tasks/cancel for a task, and run it.

import { Mastra } from '@mastra/core/mastra'
import { LibSQLStore } from '@mastra/libsql'

export const mastra = new Mastra({
// A local file is only shared by instances on the same host. Use a database every instance can reach.
storage: new LibSQLStore({ id: 'mastra', url: 'file:./mastra.db' }),
backgroundTasks: {
enabled: true,
// MCP tasks share the per-agent limit (5) unless their owner gets its own
perOwnerConcurrency: { 'mcp-server:reports': 50 },
},
mcpServers: { reports: server },
})
  • This needs a storage adapter that keeps a TTL per task. On an older adapter the server falls back to memory and logs a warning when it's registered. A store passed in tasks always wins.
  • A server that served tasks before it was registered kept those in memory. After registration it still serves them from memory on that instance, and keeps new tasks in Mastra storage.
  • Register the same server, with the same id and tools, on every instance. An instance without it can't run that server's tasks, and fails them instead. Set the id with id, or register the server under a key in mcpServers: a generated id changes on every start, and the server logs a warning.
  • Each task counts against perOwnerConcurrency for mcp-server:<server id> (or perAgentConcurrency when unset) and against globalConcurrency. Keep backpressure at its default, 'queue'. With other values a full queue fails the call.
  • An answer sent with tasks/update is stored before it's acknowledged. The task resumes even if the instance that took the answer stops before running it.
  • The server deletes a finished task's row about a minute after its ttlMs ends, without backgroundTasks.cleanup. It only deletes its own rows. A task with ttlMs: null keeps its row until you delete it.
  • Expired tasks are failed, and answered tasks still waiting are resumed, by a periodic sweep on each instance. The sweep starts when the server is registered. An instance sweeps even before any client sends it a task request. Each sweep handles at most 100 expired tasks and checks 100 other open tasks, moving on to the next 100 on the following sweep. It restarts a task left waiting by a stopped instance only when a slot is free. The rest wait for a later sweep.
  • maxOpenTasksPerClient and maxOpenTasks count open tasks in storage across every instance, so they hold for the whole deployment. Each call counts before it adds its task, so calls arriving at the same moment on different instances can go over a cap by up to the number of those calls.
  • tasks/cancel can reach any instance. If the task's first attempt is still waiting for a slot on the instance that started it, that instance stops it on its next sweep, up to one lease (leaseDurationMs) later.

Tasks are rows in the background tasks storage, owned by agent id mcp-server:<server id> with resource id mcp-server:<server id>:<caller>:

  • They don't belong to any agent, thread or memory, so they never appear in an agent's conversation.
  • The /background-tasks routes don't return them to a caller scoped to a resource. Callers without a resource scope can list them, as can the global onTaskComplete and onTaskFailed hooks. Check task.agentId.startsWith('mcp-server:') in hooks that only expect agent tasks.
  • A row is looked up by hashes of the task id and of the caller's principal, and stores neither value nor the bearer token. It does store the request context without the bearer token, and a readable metadata.clientLabel that keeps the client id with the subject or user id (next bullet). authInfo.extra and user are stored unchanged, so they're visible to anyone who can read the row.
  • To show which client sent a task, metadata.clientLabel holds the OAuth client id with the token's subject (<client id>:sub:<subject>), or with the id of the user mapAuthInfoToUser returned (<client id>:user:<user id>). Without either, it holds the client id and the first 8 characters of the token's hash (<client id>:token:<hash prefix>). The label is for debugging only: the server matches callers on the hashed principal, never on the label.

Recovery after a crash
Direct link to Recovery after a crash

When the instance running a task stops, another instance notices it at its next sweep: after the task's lease lapses if the instance crashed, or right away if the instance shut down and handed the lease back. Every instance with the server registered on a Mastra instance with background tasks sweeps, whether or not it has served a task request. An instance that keeps tasks in memory, or without the server registered, doesn't. What happens next depends on recover:

  • recover: false (default): the task ends failed with task lost: the server restarted while it was running. execute never runs twice.
  • recover: true: another instance runs execute again from the start, once. Only turn it on for tools that are safe to repeat.

With recover: true the same single retry also covers an attempt that fails for a reason other than the tool, such as a storage write failing while the task records that it needs input. An error from the tool itself isn't retried: it ends the task with an isError result.

recover doesn't affect a task waiting for input: answering it resumes it on any instance either way.

Tracing
Direct link to Tracing

Each task is one trace. The tools/call that creates the task is its root span, and it ends without an error once the task starts. Every run of the tool is a task attempt <tool> #<n> span under it, including resumed and recovered runs on other instances, and the tool's own spans sit inside its attempt. tasks/update and tasks/cancel requests join the same trace. tasks/get polls aren't traced.

The same holds for run_<workflow> and ask_<agent> tasks. A workflow task's run sits under its first attempt, or under the root in 'auto' mode, and each run resumed after tasks/update sits under the attempt that resumed it, on whichever instance that is. An agent task's agent run sits under its attempt. A run that Mastra's workflow recovery restarts after a crash starts a trace of its own, by design. Durable workflows and agents cover tracing across a crash. The task's attempt that follows the restarted run stays in the task's trace.

Task details are in span metadata: mcpTaskKey (a SHA-256 hash of the task id), attempt, resumed, recovered and mcpTaskStatus. Spans never record the task id itself. The server writes out the root span as soon as the task starts. A trace without its root drops out of trace lists, and this keeps it listed even if that instance stops before its next export. Attempts are numbered across instances, so a run recovered after a crash shows as #2 even when the crashed attempt's span was lost with its instance.

Limits
Direct link to Limits

  • Without Mastra background tasks, tasks are kept in memory. They're lost when the process restarts, and a multi-instance deployment needs every tasks/* request for a task to reach the instance that created it. Streamable HTTP requests carry the task id in the Mcp-Name header, so a load balancer can route on it.
  • Progress and log notifications sent after the task handle is answered are dropped.
On this page