Built-in MCP server
The Mastra server exposes your agents, workflows, and tools to Model Context Protocol (MCP) clients at /api/mcp. You don't define an MCPServer for it: every agent, workflow, and tool registered on the Mastra instance is available, and agents you add later appear automatically.
Each MCP tool is a thin wrapper around an existing HTTP route. A call runs through the same validation, authentication, RBAC, FGA, and ownership checks as the HTTP request, with the same RequestContext. An MCP client can never do more than the same user can do over the HTTP API.
Use the built-in server to let coding assistants and other MCP clients drive the Mastra app you already run. To publish a hand-picked set of primitives with your own prompts and resources, create an MCPServer instead.
InstallationDirect link to Installation
The endpoint uses @mastra/mcp, which @mastra/server declares as an optional peer dependency. Install it in your project to enable the built-in server:
- npm
- pnpm
- Yarn
- Bun
npm install @mastra/mcp@latest
pnpm add @mastra/mcp@latest
yarn add @mastra/mcp@latest
bun add @mastra/mcp@latest
Without it, the rest of the server keeps working, and /api/mcp answers with HTTP 501 and a JSON-RPC error that says to install the package. mastra build only adds @mastra/mcp to the build output when your project installs it.
When it's servedDirect link to When it's served
| Environment | Served at /api/mcp |
|---|---|
mastra dev | Yes, also without auth. Requests with a foreign Origin or Host header are rejected to block DNS rebinding. This also blocks tunnels and LAN IP addresses. To reach the endpoint from another machine, set server.host to the hostname or IP address clients connect to. Binding to 0.0.0.0 doesn't allow other hosts. |
Production build with server.auth | Yes. Every request needs a bearer token. |
Production build without server.auth | No, the endpoint returns 404. |
Set server.mcp.enabled to override the default. false turns the endpoint off everywhere. true serves it even without server.auth and logs a warning, because anyone who can reach the server can then call your agents, workflows, and tools.
Connect a clientDirect link to Connect a client
Start the dev server, then point a client at the endpoint. With Claude Code:
claude mcp add --transport http mastra http://localhost:4111/api/mcp
With auth configured, pass a bearer token:
claude mcp add --transport http mastra https://my-app.example.com/api/mcp \
--header "Authorization: Bearer $MASTRA_TOKEN"
To inspect the tools from the command line, use the MCP Inspector:
npx @modelcontextprotocol/inspector --cli http://localhost:4111/api/mcp \
--transport http --header "Authorization: Bearer $MASTRA_TOKEN" --method tools/list
Cursor and VS Code read the server from a JSON file. Add it to .cursor/mcp.json for Cursor:
{
"mcpServers": {
"mastra": {
"url": "http://localhost:4111/api/mcp",
"headers": { "Authorization": "Bearer <your-token>" }
}
}
}
Or to .vscode/mcp.json for VS Code:
{
"servers": {
"mastra": {
"type": "http",
"url": "http://localhost:4111/api/mcp",
"headers": { "Authorization": "Bearer <your-token>" }
}
}
}
Leave out headers when the server runs without server.auth. Clients that take only a URL and can't send an Authorization header, such as custom connectors in the Claude apps, need OAuth discovery and an identity provider that lets them sign in.
The endpoint uses stateless Streamable HTTP and speaks both MCP 2026-07-28 and the 2025 revisions that current clients use, so no session is kept between requests.
In StudioDirect link to In Studio
The MCP Servers page in Studio shows the built-in server as Mastra (built-in), above the MCP servers you register yourself. It shows the endpoint URL (including a custom server.mcp.path or apiPrefix), whether the endpoint is open or protected by server.auth, the toolsets it serves (include and exclude aren't reflected there), and copyable setup for Claude Code, Cursor, VS Code, and the MCP Inspector. The setup uses a <your-token> placeholder. Replace it with your own bearer token. If the endpoint is turned off, or @mastra/mcp isn't installed, the card says why and how to fix it.
The endpoint URL is built from the address Studio uses to reach your server. If MCP clients reach the server through a different public URL, for example behind a proxy, use that URL in the setup instead. With OAuth discovery, a client that can sign in doesn't need the Authorization header.
Studio reads this from GET /api/mcp/builtin, which needs the mcp:read permission. Studio doesn't list the server's tools or let you try them, because the tool list depends on the caller's token. Connect a client to see your tools.
ToolsDirect link to Tools
The server groups tools into toolsets. This section covers agents, workflows, and tools; see Memory, observability, and agent builder for the others. Agents and workflows are reached through operation tools that take the id as an argument. The tool list stays the same size however many agents you add.
See the built-in MCP tools reference for every tool's arguments, route, and permission.
| Toolset | Tools | Backing HTTP route |
|---|---|---|
agents | list_agents, get_agent, call_agent | GET /agents, GET /agents/:agentId, POST /agents/:agentId/generate |
workflows | list_workflows, get_workflow, start_workflow, get_workflow_run, resume_workflow_run, cancel_workflow_run | GET /workflows, GET /workflows/:workflowId, POST /workflows/:workflowId/start-async, GET /workflows/:workflowId/runs/:runId, POST /workflows/:workflowId/resume-async, POST /workflows/:workflowId/runs/:runId/cancel |
tools | tool_<id> for each registered tool | POST /tools/:toolId/execute |
- When a caller can see up to
enumLimitagents or workflows (25 by default), theagentIdandworkflowIdarguments list the allowed ids. Above that they're plain strings, and the client callslist_agentsorlist_workflowsto find ids. - When a caller can see more than
enumLimittools, thetool_<id>tools are replaced bylist_tools,get_tool, andcall_tool. call_agentreturns the reply text and athreadId. Pass thethreadIdback to continue the conversation in the agent's memory. A call without athreadIdstarts a new thread, so a client that never passes one stores a thread for each call.start_workflowwaits for the run to finish or suspend. A suspended run returns itsrunIdand suspend payload. Continue it withresume_workflow_run.- Agents without a
descriptionget a generic one and a warning in the logs. Add descriptions so clients can pick the right agent.
Featured agents and workflowsDirect link to Featured agents and workflows
List agents and workflows under featured to give them a dedicated tool. A featured workflow's tool takes the workflow's input schema directly:
import { Mastra } from '@mastra/core'
export const mastra = new Mastra({
// ...agents, workflows, tools
server: {
mcp: {
featured: { agents: ['support-agent'], workflows: ['refund'] },
},
},
})
This adds agent_support-agent and workflow_refund next to the operation tools.
Choose what to exposeDirect link to Choose what to expose
Use include and exclude to select primitives by id. Each entry matches both a primitive's own id and the key it's registered under in new Mastra({ ... }), so deleteAccountTool and delete-account select the same tool. exclude wins over include. Excluded primitives are hidden from the tool list, and calling one by either name fails as not found. This includes passing an excluded agent's id to the memory tools. Observability tools aren't filtered: their results can still include traces, logs, and scores from excluded primitives. The selection only affects MCP. The HTTP routes are unchanged.
import { Mastra } from '@mastra/core'
export const mastra = new Mastra({
server: {
mcp: {
toolsets: ['agents', 'tools'],
include: { agents: ['support-agent', 'billing-agent'] },
exclude: { tools: ['delete-account'] },
},
},
})
Set readOnly: true to serve only tools whose backing route needs a read permission, such as list_agents and get_workflow_run.
Clients can narrow the configuration per connection with ?toolsets=agents,tools or the X-MCP-Toolsets header, and ?readonly=1 or the X-MCP-Readonly header. These can only remove tools, never add tools the configuration doesn't serve.
Authentication and permissionsDirect link to Authentication and permissions
The endpoint accepts credentials only from the Authorization: Bearer header, verified by server.auth. Session cookies, the apiKey query parameter, and Studio's auth configuration don't apply to it, so a browser session can't be used to reach it from another site. Other routes keep their existing auth behavior.
With RBAC configured, MCP access needs two permissions on top of the route permissions:
mcp:readto connect and list tools.mcp:executeto see and call any tool. A caller without it gets an empty tool list, even when it may run the same operations over HTTP.
Each call also needs the backing route's permission, for example agents:execute for call_agent and workflows:execute for start_workflow. The default viewer role has mcp:read but not mcp:execute; member, admin, and owner have both. These are the same permissions that guard the routes of MCPServer instances you register, so a role granted them for your own MCP servers can also use the built-in one.
The tool list is filtered per caller. Agents and workflows appear when the caller would see them in GET /agents or GET /workflows, including FGA read checks. Tools the caller would get a 403 for are hidden. Every call is checked again when it runs, so a stale list can't be used to bypass a permission.
Thread and run ownership work like the HTTP API: a user can't continue another user's thread or resume another user's workflow run.
The inbound bearer token isn't passed to tools as an argument and isn't forwarded to other servers. Tools receive the same RequestContext as the equivalent HTTP request.
Memory, observability, and agent builderDirect link to Memory, observability, and agent builder
These toolsets are on by default. Each is served only when the server can back it, and each tool calls the HTTP route listed for it with the same permission and ownership checks.
| Toolset | Tools | Served when |
|---|---|---|
memory | list_threads, get_thread, list_messages, search_memory, get_memory_status, get_observations, create_thread, update_thread, clone_thread, save_messages | An agent has memory, or stored agents are configured |
observability | query_traces, aggregate_traces, query_spans, aggregate_spans, query_threads, get_trace_query_fields, get_trace_query_values, get_trace, get_span, get_trace_trajectory, list_logs, list_traces | Storage has an observability domain |
observability_analytics | analyze_metrics, analyze_scores, analyze_feedback, list_metric_names, list_scores, list_feedback | Storage has an observability domain |
stored_agents | list_available_models, list_stored_agents, get_stored_agent, create_stored_agent, update_stored_agent | The editor and an agents storage domain are configured |
- Memory tools take
agentIdandthreadId.resourceIddefaults to the caller whenserver.authmaps users to resources withmapUserToResourceId, and a caller can't read or write another resource's threads, messages, or Observational Memory. Without that mapping, a suppliedresourceIdis used as is, as on the HTTP API. - Observability tools depend on what the storage supports. A store with trace queries, such as DuckDB, serves
query_tracesand the other query tools; a store without them serveslist_tracesinstead. Tools for logs, metrics, scores, and feedback appear only when the store implements them. get_tracereturns the light trace: every span's ids, parent, name, type, timing, status, and entity. Callget_spanfor one span's input, output, and attributes. List and trace results are kept under about 40,000 characters. A cut result says how to get the rest: a smaller page size, orget_tracewith the returnednextOffset. A single item that's still too big, such as a message with an inline image, comes back with its long values cut and marked.get_spanreturns a span in full.get_observationscuts an oversized record the same way.analyze_metrics,analyze_scores, andanalyze_feedbacktake anopofaggregate,breakdown,timeseries, orpercentiles. Each op runs the matching/observability/<source>/<op>route.create_stored_agentandupdate_stored_agentpublish the change by default, so the agent can be called withcall_agentright away. PassautoPublish: falseto keep a draft. TheagentIdargument ofcall_agentlists only the agents that existed when the client fetched the tool list, so a client must list the tools again before it can call a newly created agent.
In read-only mode, the query and aggregate tools stay available because their routes need observability:read, even though they're POST routes. Memory and stored-agent writes are removed. get_observations stays, without its waitForPending option, because waiting runs a route that needs memory:write. Callers without memory:write also get get_observations without that option.
Observability data isn't scoped to the caller. Anyone with observability:read can read every user's traces, logs, scores, and feedback, over MCP and over HTTP. The default viewer role includes it through *:read. Leave out the observability toolsets, or remove observability:read from roles that shouldn't see other users' data:
import { Mastra } from '@mastra/core'
export const mastra = new Mastra({
server: {
mcp: { toolsets: ['agents', 'workflows', 'tools', 'memory', 'stored_agents'] },
},
})
Agents creating agentsDirect link to Agents creating agents
create_stored_agent lets an MCP client create agents that other clients can then call. It needs stored-agents:write. A created agent is owned by the caller and is private by default. Without a caller identity, such as when server.auth isn't configured, the agent has no owner and is public, even with visibility: 'private'. Other users don't see a private agent in list_agents or list_stored_agents, and unless they're admins or granted that agent by id, calling it answers as if it doesn't exist, the same as over HTTP. Set visibility: 'public' to let every user call it. Only the owner, admins, and users granted that agent by id can edit it either way. An agent with no owner can be edited by any user with stored-agents:write. See stored agent visibility. When the agent builder restricts models, the MCP tools accept only allowed models for the agent's model (the provider and name it runs, in each conditional variant), for any model named in defaultOptions (such as structuredOutput.model), for each enabled entry of the fallback modelList, for the memory models (the Observational Memory observer and reflector models and the title model), and for the model of every input and output processor step, at any depth of the processor graph. They also reject anything that would run on a default model the policy can't check: observationalMemory: true, an Observational Memory config without a model, memory left out on create (which the agent builder fills with Observational Memory on its default model), or a processor that needs a model, such as moderation, without one. Set an allowed model instead. A stored model takes only provider, name, and model settings such as temperature. Other keys, such as modelId or providerId, are rejected. If the agent builder's policy can't be loaded, the tools return an error instead of saving unchecked. list_available_models returns the allowed models. The HTTP route doesn't apply this check, so a user who can call POST /stored/agents directly can still save any model. Embedding models (memory.embedder) aren't on the builder's model list and aren't checked. Tool allowlists aren't checked when an agent is saved, so a created agent can use any tool the server has. Its tools run with the permissions of the user who calls it.
Observational Memory starts observing only after a thread reaches its token threshold, 30,000 tokens by default. For a short demo, set a lower observation.messageTokens so the agent learns from a few messages. Observational Memory processes messages in the background, so right after call_agent, call get_observations with waitForPending: true to wait (up to 30 seconds) for it to finish before reading.
OAuth discoveryDirect link to OAuth discovery
When server.auth is configured, the endpoint tells MCP clients how to get a token, following the MCP authorization spec. A request without a valid token gets a 401 whose WWW-Authenticate header points at the endpoint's protected-resource metadata (RFC 9728):
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://my-app.example.com/.well-known/oauth-protected-resource/api/mcp"
When the request carried a token that failed verification, for example because it expired, the header also includes error="invalid_token". Other routes return their 401 without this header.
The metadata is public and served at /.well-known/oauth-protected-resource/api/mcp and /.well-known/oauth-protected-resource, outside the API prefix:
{
"resource": "https://my-app.example.com/api/mcp",
"authorization_servers": ["https://my-tenant.us.auth0.com/"],
"bearer_methods_supported": ["header"]
}
A client reads authorization_servers, runs the OAuth flow against your identity provider, and retries with the access token. Mastra doesn't act as an authorization server. Whether a client can sign in without a pre-registered OAuth app depends on your identity provider supporting dynamic client registration or client ID metadata documents.
Mastra fills the metadata from server.mcp.oauth and from your auth provider:
resourcedefaults to the endpoint URL derived from the request. Behind a proxy or load balancer, the request may carry an internal host, so setserver.mcp.oauth.resourceto the public URL. It must be an absolutehttpsURL (httpis allowed forlocalhost) without a fragment. An invalid value logs an error and turns OAuth discovery off, but the token audience is still checked against it, so MCP requests are rejected until you fix the value.- The metadata URL in the challenge uses the origin of
resourcewith the endpoint's path on the server, for example/.well-known/oauth-protected-resource/api/mcp. If your proxy serves the endpoint under a different public path, it must also forward that well-known URL to Mastra. authorization_serverscomes from the provider when it knows its issuer (see the table below). Otherwise setserver.mcp.oauth.authorizationServers. Without either, the field is left out and clients need a token configured by hand.scopes_supportedis only included when you setserver.mcp.oauth.scopesSupported.
import { Mastra } from '@mastra/core'
import { MastraAuthWorkos } from '@mastra/auth-workos'
export const mastra = new Mastra({
server: {
auth: new MastraAuthWorkos(),
mcp: {
oauth: {
resource: 'https://my-app.example.com/api/mcp',
authorizationServers: ['https://my-app.authkit.app'],
},
},
},
})
Token audienceDirect link to Token audience
Setting server.mcp.oauth.resource also makes the endpoint check that a token was issued for it: the token's aud claim must include the resource value. A token for another API gets a 401 with error="invalid_token", so a token leaked from a different service can't be replayed against your MCP endpoint. Without resource, any token your auth provider accepts works, as it did before. The check only applies to /api/mcp: other routes still accept a token issued for a different API. The comparison is exact, so https://my-app.example.com/api/mcp/ with a trailing slash doesn't match https://my-app.example.com/api/mcp.
Configure your identity provider to issue tokens with that audience, for example by registering the resource URL as an API identifier in Auth0. Auth0 also verifies its own audience option (AUTH0_AUDIENCE), so either set that option to the resource URL or issue tokens whose aud includes both values.
| Provider | Authorization server advertised | Audience checked against resource |
|---|---|---|
MastraJwtAuth | issuer option | Yes |
| Auth0 | https://<domain>/ | Yes |
| Okta | issuer option | No. Okta checks the token against its own audience option, so set that to the resource URL. |
| Clerk | Frontend API URL from the publishable key. Your OAuth application must issue JWT access tokens (the default for applications created since January 2026). Opaque tokens are rejected. | No |
| Supabase | <url>/auth/v1. Clients can only sign in there if the OAuth 2.1 server is turned on for your Supabase project. | No |
CompositeAuth | All inner providers' servers | Delegated to the provider that authenticated the token |
| WorkOS, Google, Neon, Firebase, Better Auth, custom providers | Set authorizationServers | No |
When resource is set and a provider can't check the audience, Mastra logs a warning at startup and accepts that provider's tokens for any audience. This keeps API keys and other opaque tokens working. To reject them on /api/mcp instead, set requireAudience: true:
import { Mastra } from '@mastra/core'
import { CompositeAuth, SimpleAuth } from '@mastra/core/server'
import { MastraJwtAuth } from '@mastra/auth'
const jwtAuth = new MastraJwtAuth({
jwksUri: 'https://auth.example.com/.well-known/jwks.json',
issuer: 'https://auth.example.com/',
})
const apiKeyAuth = new SimpleAuth({ tokens: { [process.env.API_KEY!]: { id: 'ci' } } })
export const mastra = new Mastra({
server: {
auth: new CompositeAuth([jwtAuth, apiKeyAuth]),
mcp: {
oauth: { resource: 'https://my-app.example.com/api/mcp', requireAudience: true },
},
},
})
With requireAudience, a request authenticated by a provider without an audience check, such as SimpleAuth, gets a 401 with error="invalid_token" on /api/mcp. Inside CompositeAuth, only requests accepted by those providers are rejected. Other routes still accept them. requireAudience needs resource, and without resource nothing is checked.
Custom providers opt in by implementing verifyAudience() and getOAuthMetadata(), see custom auth providers.
Connect an OAuth clientDirect link to Connect an OAuth client
With an identity provider that supports dynamic client registration, add the server without a token and the client runs the sign-in flow on first use:
claude mcp add --transport http mastra https://my-app.example.com/api/mcp
Otherwise, get an access token for the resource audience from your identity provider and pass it as a header:
claude mcp add --transport http mastra https://my-app.example.com/api/mcp \
--header "Authorization: Bearer $MASTRA_TOKEN"
The endpoint doesn't return 403 with error="insufficient_scope" yet. A caller without mcp:read gets a plain 403, because Mastra permissions aren't OAuth scopes a client could request.
LimitationsDirect link to Limitations
- Workflows run synchronously within the MCP request. Long-running workflows are bounded by your server's request timeout.
- There's no per-primitive opt-out on the
Agent,Workflow, or tool constructors yet. Useserver.mcp.includeandserver.mcp.exclude. - Working memory, deleting threads or stored agents, and scorer and workspace management aren't exposed.