Skip to main content

MCPClient

The MCPClient class provides a way to manage multiple MCP server connections and their tools in a Mastra application. It handles connection lifecycle and tool namespacing while providing access to tools across all configured servers.

note

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

Constructor
Direct link to Constructor

Creates a new instance of the MCPClient class.

constructor({
id?: string;
servers: Record<string, MastraMCPServerDefinition>;
timeout?: number;
typegen?: { outFile: string };
clientInfo?: { name?: string; version?: string };
registerProcessHooks?: boolean;
}: MCPClientOptions)

MCPClientOptions
Direct link to MCPClientOptions


id?:

string
Optional unique identifier for the configuration instance. Use this to prevent memory leaks when creating multiple instances with identical configurations.

servers:

Record<string, MastraMCPServerDefinition>
A map of server configurations, where each key is a unique server identifier and the value is the server configuration.

timeout?:

number
= 60000
Global timeout value in milliseconds for all servers unless overridden in individual server configs.

typegen?:

{ outFile: string }
Enables optional CLI generation for this client. outFile must be a nonempty path, resolved from the command working directory. Construction stores readonly metadata and does not generate files.

clientInfo?:

{ name?: string; version?: string }
Client name and version every server receives on connect and with each request, so servers can attribute traffic to your application. Defaults to the server key as name and '1.0.0' as version. Individual server configs can override either field with their own clientInfo. Doesn't change tool name prefixes, which always use the server key.

registerProcessHooks?:

boolean
= true
Install process exit and signal hooks that disconnect servers and end the process. Set to false when your application handles shutdown, and call disconnect() from that handler. See Handle shutdown yourself.

webhook?:

MCPClientWebhookOptions
Experimental. Enables webhook delivery for MCP events. Servers POST occurrences to webhook.url, which must route to events.handleWebhook().

Typed client contracts
Direct link to Typed client contracts

Pass an explicit server-map type to MCPClient<TServers> to check configured server names, discovered tool names, and direct-call arguments. MCPClientOptions<TServers> accepts the same map. All declared servers are required. Use Pick<TServers, 'weather'> to configure a subset. Without a type argument, discovery keeps its existing broad types.

The map describes each server's tools, with input and output types for each tool. This example assumes a running weather server at WEATHER_MCP_URL:

import { MCPClient } from '@mastra/mcp'

interface MCPServers {
weather: {
tools: {
get_weather: {
input: { location: string }
output: { temperature: number; conditions: string }
}
}
}
}

const mcp = new MCPClient<MCPServers>({
servers: { weather: { url: new URL(process.env.WEATHER_MCP_URL!) } },
})

try {
const tools = await mcp.listTools()
const weather = tools.weather_get_weather
if (weather?.execute) {
const result = await weather.execute({ location: 'Paris' })
if (result && 'temperature' in result && typeof result.temperature === 'number') {
console.log(result.temperature)
}
}
} finally {
await mcp.disconnect()
}

listTools() and listToolsWithErrors().tools expose exact server_tool keys. listToolsets() and listToolsetsWithErrors().toolsets expose server keys containing raw tool names. Both forms are partial: a server or tool can be absent. Check availability before calling a tool. Error maps, error details, and optional discovery durations keep their existing types.

Execution can return the structured output, a protocol result envelope, a validation error, or undefined. Check the shape and property types before reading success fields. Protocol envelopes can have additional properties. Set output: unknown for tools without an output schema.

These contracts describe a schema snapshot. Live servers can differ from that snapshot. Update them when server schemas change, using equivalent permissions. Extra live tools remain available at runtime but aren't included in the snapshot's known keys. Runtime discovery, validation, and execution are unchanged.

Generate client types
Direct link to Generate client types

Generate concrete TypeScript interfaces from live tool input and output schemas with the command included in @mastra/mcp. Export your existing client and set typegen.outFile:

src/mcp/client.ts
import { MCPClient } from '@mastra/mcp'
import type { MCPServers } from './mcp-types.generated'

export const mcp = new MCPClient<MCPServers>({
id: 'weather-client',
servers: { weather: { url: new URL(process.env.WEATHER_MCP_URL!) } },
typegen: { outFile: 'src/mcp/mcp-types.generated.ts' },
})

Set the environment variables and authentication credentials your module needs, then run this command from your project root:

npx @mastra/mcp generate ./src/mcp/client.ts

The generated type-only import can be missing on the first run. The loader handles TypeScript and transitive imports without typechecking. Run your typecheck after generation.

Use the generated names and check runtime availability and result shape. This example assumes the server publishes get_weather with a required string location and a numeric temperature output:

src/mcp/call-weather.ts
import { mcp } from './client'

try {
const tools = await mcp.listTools()
const weather = tools.weather_get_weather
if (weather?.execute) {
const result = await weather.execute({ location: 'Paris' })
if (result && 'temperature' in result && typeof result.temperature === 'number') {
console.log(result.temperature)
}
}
} finally {
await mcp.disconnect()
}

The command accepts one or more file paths. Both input files and outFile paths are relative to the command's working directory. It selects direct named and default exports of typegen-enabled clients. It doesn't scan your source tree, invoke factories, or inspect nested exported objects. Repeated files, symlink aliases, and exports of the same client are processed once. A file without an eligible client fails.

For independent clients with identical server configurations, use distinct IDs and output paths:

src/mcp/clients.ts
import { MCPClient } from '@mastra/mcp'

const servers = { weather: { url: new URL(process.env.WEATHER_MCP_URL!) } }
export const first = new MCPClient({
id: 'first',
servers,
typegen: { outFile: 'first.generated.ts' },
})
export const second = new MCPClient({
id: 'second',
servers,
typegen: { outFile: 'second.generated.ts' },
})
npx @mastra/mcp generate ./src/mcp/clients.ts

The existing instance cache still applies. Constructing a client with the same explicit ID, server configuration, and typegen.outFile returns the existing object. A different server configuration or outFile replaces the cached instance instead, so a later constructor never silently keeps an output file it wasn't given.

Generation lists tool definitions without calling tools. It fails on discovery errors, missing authentication, timeouts, output collisions, outputs that overwrite supplied input files, and schemas that can't be represented at all, such as reference cycles, excessive depth, or unresolved local references. Each failure names the client file, server, or tool it concerns. It prepares all sources before replacing files and disconnects selected clients on success or failure. Discovery and preflight failures preserve existing outputs. Each replacement is atomic, but multiple replacements aren't one transaction. A filesystem failure during replacement can leave earlier files updated.

Tools without output schemas use unknown. Constraints such as string formats, length limits, and numeric bounds retain their base TypeScript types and remain runtime validation rules. Unsupported schema portions, including conditional schemas and prefixItems tuples, widen to unknown with a warning. A keyword whose value is outside its allowed shape widens that schema to unknown with a warning too, so one unusable tool doesn't stop generation for other servers. Warnings name the server, tool, and schema position they concern. External schema references aren't fetched. Unresolved local references and reference cycles fail generation. Schemas nested deeper than 128 levels fail generation too, matching the protocol depth limit. A schema that exceeds the protocol node budget of 10,000 widens to unknown with a warning, as does any schema or catalogue that exceeds the larger budgets generation applies to raw JSON values. Generation measures those budgets before it copies or converts a schema, so a server that advertises large schemas can't stall the command.

Module execution and credentials

The command executes the supplied modules and their imports. Use trusted modules without application startup side effects. Provide credentials before invoking the command. It doesn't load dotenv files or launch interactive authentication. Existing client timeout and discovery settings apply.

Generator diagnostics omit raw schemas, transport errors, and credentials. Logs emitted by your imported application, custom log handlers, or server subprocesses remain outside that guarantee.

MastraMCPServerDefinition
Direct link to mastramcpserverdefinition

Each server in the servers map is configured using the MastraMCPServerDefinition type. The transport type is detected based on the provided parameters:

  • If command is provided, it uses the Stdio transport.
  • If url is provided, it uses the Streamable HTTP transport.

The client speaks the MCP 2026-07-28 revision. On connect it asks the server which revisions it offers (server/discover) and falls back to the pre-2026 initialize handshake for servers that haven't upgraded, so tools, resources and prompts keep working against either. Features the older revisions lack (resources.subscribe(), inputRequests) fail with an error naming the negotiated revision. onListChanged() handlers work against either revision, since older servers send list-changed notifications without a subscriptions/listen stream. Set protocolVersion to skip the probe.


command?:

string
For Stdio servers: The command to execute.

args?:

string[]
For Stdio servers: Arguments to pass to the command.

env?:

Record<string, string>
For Stdio servers: Environment variables to set for the command.

inheritDefaultEnv?:

boolean
= true
For Stdio servers: Whether the subprocess environment starts from the MCP SDK's default inherited environment. The default is a curated whitelist, not the full process environment: on POSIX it inherits HOME, LOGNAME, PATH, SHELL, TERM, and USER; on Windows it inherits APPDATA, HOMEDRIVE, HOMEPATH, LOCALAPPDATA, PATH, PROCESSOR_ARCHITECTURE, SYSTEMDRIVE, SYSTEMROOT, TEMP, USERNAME, and USERPROFILE. When set to false, only the variables explicitly listed in env are passed to the subprocess. Note that a subprocess without PATH may fail to spawn commands that are not absolute paths.

url?:

URL
For HTTP servers: The URL of the server.

requestInit?:

RequestInit
For HTTP servers: Request configuration for the fetch API.

fetch?:

MastraFetchLike
For HTTP servers: Custom fetch implementation used for all network requests. Receives an optional third requestContext parameter containing request-scoped data (e.g., authentication cookies, bearer tokens) from the incoming request. When provided, this function will be used for all HTTP requests, allowing you to add dynamic authentication headers, forward request-scoped credentials to the MCP server, customize request behavior per-request, or intercept and modify requests/responses. When fetch is provided, requestInit and authProvider become optional, as you can handle these concerns within your custom fetch function.

allowedHosts?:

string[]
For HTTP servers: Opt-in allowlist of hosts the client may contact on behalf of this server. Each entry is matched against the URL host (hostname plus port when the URL carries a non-default port), for example "api.example.com" or "localhost:8080". Matching is exact and case-insensitive on the hostname; wildcards are not supported and the URL scheme is not checked. An empty array denies all requests. When unset, no restriction is applied. See the Security section below for enforcement details.

logger?:

LogHandler
Optional additional handler for logging.

protocolVersion?:

'2026-07-28' | 'legacy'
Pins the protocol revision instead of probing for it. '2026-07-28' connects only to servers that offer the current revision. 'legacy' uses the pre-2026 initialize handshake without probing, for servers known not to have upgraded. When omitted the client probes with server/discover and speaks whichever revision the server offers.

timeout?:

number
Server-specific timeout in milliseconds. Falls back to the client-level timeout when omitted.

clientInfo?:

{ name?: string; version?: string }
Overrides the top-level clientInfo for this server. Fields you set replace the top-level values; fields you omit keep them.

capabilities?:

MCPClientCapabilities
Client capabilities to advertise: elicitation and extensions. elicitation is only accepted together with an inputRequests handler and defaults to form support when a handler is configured. Roots and sampling are not supported.

inputRequests?:

MCPInputRequestHandler
Answers input requests from this server. When a tool, resource or prompt call ends as input_required, the handler is called with each embedded request and the client retries the call with the answers. Without a handler an input_required result surfaces as an error. See Answering input requests below.

authProvider?:

OAuthClientProvider
For HTTP servers: OAuth authentication provider for automatic token refresh and OAuth flow management. Use MCPOAuthClientProvider for a ready-to-use implementation.

enableServerLogs?:

boolean
= true
Whether to request per-request server logs. Attaches the io.modelcontextprotocol/logLevel metadata key to every request so the server delivers notifications/message for it, and forwards delivered messages to logger.

serverLogLevel?:

LoggingLevel
= 'info'
Minimum severity requested from the server for per-request logs.

enableProgressTracking?:

boolean
= false
Whether to send a progressToken with tool calls so the server can report progress. See the progress property.

traceContext?:

() => MCPTraceContext | undefined
Returns the W3C traceparent, optional tracestate and baggage to send as request _meta on every call to this server. Called when each request is sent so it can read a request-local carrier. Without this option, a tool call made by a traced agent or workflow sends the traceparent of its tool call span. When the reply names the server span that handled the call, as MCPServer does, the tool call span links to it. Set this option to send your own trace context, or return undefined to send none. Explicit _meta keys on a tool call take precedence. Servers built with MCPServer link their request span to the span the traceparent names and expose the received values to tools as requestContext.get("traceContext"); they are observability data and are never used for authorization.

forwardInstructions?:

boolean
= false
Whether to append instructions advertised by this MCP server to an agent's system prompt when the agent uses this server's tools. Disabled by default; enable it only for servers you trust, since the instructions are injected into the agent's system prompt.

instructionsMaxLength?:

number
= 512
Maximum number of server instruction characters to append to an agent's system prompt.

requireToolApproval?:

boolean | (params: RequireToolApprovalContext) => boolean | Promise<boolean>
Require human approval before executing tools from this server. When set to true, all tools require approval. When set to a function, the function is called with the tool name, arguments, request context, and any tool annotations advertised by the server to dynamically decide whether approval is needed.

onToolError?:

'throw' | 'return'
= 'throw'
How to handle tool execution failures the server reports in-band with isError: true. 'throw' raises a MastraError carrying the server's error text, so the failure reaches tool spans, stream chunks, scorers, and the model. Any structuredContent is kept on error.details.structuredContent as a JSON string. 'return' resolves with the full CallToolResult (isError, content, and structuredContent) instead of throwing.

jsonSchemaValidator?:

JsonSchemaValidator
Validator used for MCP tool schemas and structured results. The default supports JSON Schema 2020-12. Provide a compatible validator such as CfWorkerJsonSchemaValidator in runtimes that disallow dynamic code generation.

Tool approval
Direct link to Tool approval

Use requireToolApproval on a server definition to require human approval before any tool from that server is executed. This works with the existing human-in-the-loop approval flow.

Require approval for all tools
Direct link to Require approval for all tools

Set requireToolApproval to true to require approval for every tool on the server:

const mcp = new MCPClient({
servers: {
github: {
url: new URL('http://localhost:3000/mcp'),
requireToolApproval: true,
},
},
})

Dynamic approval with a function
Direct link to Dynamic approval with a function

Pass a function to decide per-call whether approval is needed. The function receives the tool name, the arguments the model passed, any request context from the incoming request, and the tool's MCP annotations (when the server advertises them):

const mcp = new MCPClient({
servers: {
github: {
url: new URL('http://localhost:3000/mcp'),
requireToolApproval: ({ toolName, args, requestContext }) => {
// Read-only tools don't need approval
if (toolName === 'list_repos') return false
// Destructive tools with force flag always need approval
if (toolName === 'delete_repo') return args.force === true
// Non-admin users need approval for everything else
return requestContext?.userRole !== 'admin'
},
},
},
})

The function can also be async. It receives requestContext from the incoming request, which you can use for auth checks or other per-request logic.

Use tool annotations from a trusted server
Direct link to Use tool annotations from a trusted server

If you trust the MCP server, you can use its tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint, title) to drive approval decisions:

const mcp = new MCPClient({
servers: {
github: {
url: new URL('http://localhost:3000/mcp'),
requireToolApproval: ({ annotations }) => {
// Skip approval for tools the server has marked read-only
if (annotations?.readOnlyHint) return false
// Always require approval for destructive tools
if (annotations?.destructiveHint) return true
return true
},
},
},
})

Per the MCP specification: clients MUST consider tool annotations to be untrusted unless they come from trusted servers. Annotations are advisory hints and provide no security boundary. A malicious or buggy server can claim a tool is read-only when it isn't. Only use annotations to relax approval requirements for servers you trust.

The same annotations are also exposed on the tools returned by listTools() and listToolsets() under tool.mcp.annotations, so you can inspect them when wiring tools into an agent.

Each tool also carries tool.title, set from the server's tool title and falling back to annotations.title. When the server provides neither, tool.title is undefined and the tool name is the display fallback, matching the MCP display-name precedence.

Server instructions
Direct link to Server instructions

When an MCP server advertises instructions during initialization, MCPClient stores them for that server. Forwarding those instructions into an agent's system prompt is opt-in: set forwardInstructions: true on a server to have agents that use its tools (via listTools() or listToolsets()) receive its instructions automatically.

The guidance is grouped by server name and truncated to instructionsMaxLength characters per server.

const mcp = new MCPClient({
servers: {
db: {
url: new URL('http://localhost:3000/mcp'),
forwardInstructions: true,
instructionsMaxLength: 512,
},
},
})

const agent = new Agent({
id: 'db-agent',
name: 'DB Agent',
instructions: 'Help with database changes.',
model,
tools: await mcp.listTools(),
})

When forwardInstructions is omitted (the default), instructions are still cached and can be inspected via getServerInstructions(), but aren't added to any agent's system prompt.

Security note: server instructions are forwarded verbatim (subject only to length truncation) into the agent's system prompt. A malicious or compromised MCP server can use them to inject instructions the agent will treat as trusted system guidance. Only enable forwardInstructions for servers you trust, and prefer reviewing instructions with getServerInstructions() before forwarding instructions from third-party servers.

Security
Direct link to Security

Subprocess environment for Stdio servers
Direct link to Subprocess environment for Stdio servers

Stdio subprocesses don't inherit the full parent process environment. By default the subprocess environment starts from the MCP SDK's curated whitelist (POSIX: HOME, LOGNAME, PATH, SHELL, TERM, USER; Windows: APPDATA, HOMEDRIVE, HOMEPATH, LOCALAPPDATA, PATH, PROCESSOR_ARCHITECTURE, SYSTEMDRIVE, SYSTEMROOT, TEMP, USERNAME, USERPROFILE), merged with any variables you set in env. Sensitive variables such as API keys aren't inherited unless you pass them explicitly.

For stricter isolation, set inheritDefaultEnv: false so only your configured env entries reach the subprocess:

const mcp = new MCPClient({
servers: {
myTool: {
command: '/usr/local/bin/my-mcp-server',
inheritDefaultEnv: false,
env: { MY_TOOL_API_KEY: process.env.MY_TOOL_API_KEY! },
},
},
})

Variables you place in env are forwarded verbatim, so treat server configurations that come from untrusted sources (for example, user-supplied config files) as untrusted input.

Restricting outbound hosts with allowedHosts
Direct link to restricting-outbound-hosts-with-allowedhosts

When HTTP server URLs come from untrusted configuration, an attacker-controlled URL can point the client at internal services (server-side request forgery). Set allowedHosts on such servers to restrict which hosts the client will contact:

const mcp = new MCPClient({
servers: {
remote: {
url: new URL(untrustedConfig.serverUrl),
allowedHosts: ['api.example.com'],
},
},
})

Enforcement details:

  • On the default fetch path, requests to disallowed hosts, including every redirect hop, are blocked before they're sent. Redirects are followed manually (up to 5 hops) so each hop is validated, and the Authorization header isn't carried across hops to a different origin (any scheme, host, or port change drops it, matching standard fetch behavior).
  • When you supply a custom fetch, the initial URL is still checked before the request, but redirect hops are validated after the fact using response.url: the outbound hop may occur, and the response is discarded when its final URL points at a disallowed host. A hand-built Response with an empty response.url skips this post-hoc check.
  • OAuth requests made through authProvider (authorization server metadata discovery, token exchange, refresh) are also validated. If your authorization server runs on a different host than the MCP server, add that host to allowedHosts too.
  • A blocked host fails the connection with a clear error and is never retried by the reconnect logic.

allowedHosts is intentionally minimal: it matches exact hosts and doesn't support wildcards or scheme checks. If you need richer policy (scheme checks, IP-range rules), supply a custom fetch implementation, which is invoked for every request the client makes.

Treat tool responses as untrusted input
Direct link to Treat tool responses as untrusted input

Tool results returned by MCP servers flow into your agent's context as model input. A malicious or compromised server can use tool output for prompt injection. The transport client doesn't sanitize tool responses: sanitization policy belongs at the agent layer, where Mastra's input and output processors let you inspect, transform, or block content before and after it reaches the model. Combine this with requireToolApproval and the forwardInstructions security note above when working with third-party servers.

Methods
Direct link to Methods

listTools()
Direct link to listtools

Retrieves all tools from all configured servers, with tool names namespaced by their server name (in the format serverName_toolName) to prevent conflicts. Intended to be passed onto an Agent definition.

new Agent({ id: 'agent', tools: await mcp.listTools() })

listToolsWithErrors(options?)
Direct link to listtoolswitherrorsoptions

Retrieves all tools from all configured servers, with tool names namespaced by their server name. Also returns per-server errors for servers that failed to connect or list tools.

Set perServerTimeoutMs to limit how long discovery waits for each server. Servers that finish within the limit remain in tools. Timed-out servers appear in errors, and durations reports each server's discovery time in milliseconds.

const { tools, errors, errorDetails, durations } = await mcp.listToolsWithErrors({
perServerTimeoutMs: 3_000,
})

new Agent({ id: 'agent', tools })
console.log(errors, errorDetails, durations)

errors remains a string map for backward compatibility. errorDetails provides the same message plus machine-readable httpStatus and transport code fields when the underlying error exposes them. When an HTTP status is available, the legacy message also includes an (HTTP nnn) suffix.

When called without options, the method omits only durations; tools, errors, and errorDetails are always returned.

listToolsets()
Direct link to listtoolsets

Returns an object mapping namespaced tool names (in the format serverName.toolName) to their tool implementations. Intended to be passed at runtime into the generate or stream method.

const res = await agent.stream(prompt, {
toolsets: await mcp.listToolsets(),
})

listToolsetsWithErrors(options?)
Direct link to listtoolsetswitherrorsoptions

Returns toolsets grouped by server name, along with per-server discovery errors. Set perServerTimeoutMs to limit each server independently and include per-server durations in milliseconds.

const { toolsets, errors, errorDetails, durations } = await mcp.listToolsetsWithErrors({
perServerTimeoutMs: 3_000,
})

const res = await agent.stream(prompt, { toolsets })
console.log(errors, errorDetails, durations)

When called without options, the method omits only durations; toolsets, errors, and errorDetails are always returned.

listToolDefinitions()
Direct link to listtooldefinitions

Returns every server's tools as plain, serializable definitions, grouped by server name and keyed by the server's own tool name (without the serverName_toolName namespacing that listTools() applies).

Unlike listTools(), the result contains no functions or references to a live client, so it can be passed through JSON.stringify and cached in Redis or a database, as well as a build artifact. Each definition holds the data from the MCP tools/list response (name, title, description, input schema, output schema, annotations, and _meta), plus the server name, version, and instructions captured at discovery time.

const definitions = await mcp.listToolDefinitions()

await cache.set('mcp-tools', JSON.stringify(definitions))

listToolDefinitionsWithErrors(options?)
Direct link to listtooldefinitionswitherrorsoptions

Like listToolDefinitions(), but also returns per-server errors for servers that failed to connect. Use this when caching a catalog, so you don't persist a partial manifest that omits a server that was down at discovery time.

Set perServerTimeoutMs to limit each server independently. When options are provided, durations reports each server's discovery time in milliseconds.

const { definitions, errors, errorDetails, durations } = await mcp.listToolDefinitionsWithErrors({
perServerTimeoutMs: 3_000,
})

if (Object.keys(errors).length === 0) {
await cache.set('mcp-tools', JSON.stringify(definitions))
}

console.log(errorDetails, durations)

When called without options, the method omits only durations; definitions, errors, and errorDetails are always returned.

toolFromDefinition()
Direct link to toolfromdefinition

Rebuilds a single executable tool from a cached definition. No connection is opened here. The client connects lazily, the first time the tool is executed.

The returned tool behaves exactly like one from listTools(), with the same strict-mode metadata, approval policy, structured content handling, tool error handling, and reconnect behavior. Successful structured results are validated against the advertised outputSchema on both paths. Invalid results reject the tool call. MCP error results skip output validation.

MCP schemas without a $schema declaration use JSON Schema 2020-12. Local $ref and $defs references, composition keywords, and boolean subschemas are supported. To bound validation work from untrusted tool catalogs, input and output schemas are limited to 128 nested subschema levels and 10,000 subschema nodes.

structuredContent can be any JSON value, including strings, numbers, booleans, and null. Object and array results also expose the MCP content and _meta envelopes through non-enumerable Mastra metadata properties. Scalar and null results can't carry those properties and remain unchanged rather than being wrapped.

const definitions = JSON.parse(await cache.get('mcp-tools'))

const tool = await mcp.toolFromDefinition({
serverName: 'weather',
definition: definitions.weather.getForecast,
})

toolsFromDefinitions()
Direct link to toolsfromdefinitions

Rebuilds an entire cached catalog into a namespaced tool map, without connecting. This is the cached counterpart to listTools(). It produces the same serverName_toolName keys, so you can reconstruct an agent's tool map on a cold start, and connections only open for the servers whose tools are actually called.

Servers present in the catalog but no longer configured on the client are skipped, so a stale cached manifest degrades gracefully.

const definitions = JSON.parse(await cache.get('mcp-tools'))

new Agent({ id: 'agent', tools: await mcp.toolsFromDefinitions({ definitions }) })

getServerInstructions()
Direct link to getserverinstructions

Returns the instructions currently known for each configured MCP server. Servers that haven't connected yet, or don't advertise instructions, return undefined.

getServerInstructions(): Record<string, string | undefined>

Example:

await mcp.listTools()

const instructionsByServer = mcp.getServerInstructions()
console.log(instructionsByServer.db)

getServerInfo()
Direct link to getserverinfo

Returns the identity each configured MCP server announced when it connected (its serverInfo, from the server/discover result or, for servers on an older revision, the initialize handshake): name and version, plus title, description, websiteUrl, and icons when the server provides them. A server's entry is undefined if it hasn't connected yet, or if it connected over the 2026-07-28 revision without announcing an identity, which that revision allows. Don't use it to check whether a server is connected. The values come from the remote server and aren't validated, so treat icon and website URLs as untrusted.

getServerInfo(): Record<string, MCPServerImplementation | undefined>

Example:

await mcp.listTools()

const info = mcp.getServerInfo()
console.log(info.myServer?.title, info.myServer?.version)

authenticate()
Direct link to authenticate

Runs the interactive OAuth authorization-code flow for a server whose MCPOAuthClientProvider uses a loopback redirect URL. A local callback server passes the authorization URL to the provider's onRedirectToAuthorization callback and waits for the browser to return the code. It then exchanges the code for tokens and reconnects. See Interactive browser authentication.

The optional timeoutMs bounds how long the flow waits for the browser to return the authorization code before rejecting, and defaults to 5 minutes.

async authenticate(serverName: string, options?: { timeoutMs?: number }): Promise<void>

getServerAuthState()
Direct link to getserverauthstate

Returns the OAuth authorization state of a configured server: 'needs-auth' after a connection attempt was rejected with an authorization error, 'authorized' once the server accepted the provider's credentials, or undefined for servers without an authProvider or that haven't attempted a connection yet.

getServerAuthState(serverName: string): 'needs-auth' | 'authorized' | undefined

cancelAuthentication()
Direct link to cancelauthentication

Cancels an in-progress authenticate() flow for a server, so an abandoned browser authorization doesn't leave the client waiting indefinitely. It aborts the flow (including its setup phase, before the callback server binds), closes the local callback server if one is listening, and the pending authenticate() call rejects. Returns true if a flow was cancelled, or false when no flow was in progress.

The resulting getServerAuthState() depends on how far the flow progressed. A flow cancelled after a 401 rejection stays at 'needs-auth' and can be retried immediately. Cancellation during setup leaves the state unchanged (typically undefined) if no connection was attempted.

async cancelAuthentication(serverName: string): Promise<boolean>

disconnect()
Direct link to disconnect

Disconnects from all MCP servers and cleans up resources.

async disconnect(): Promise<void>

toMCPServerProxies()
Direct link to tomcpserverproxies

Returns a map of MCPClientServerProxy instances, one per configured server. Each proxy wraps the underlying client connection as an MCPServerBase instance, allowing external (non-Mastra) MCP servers to be registered in mcpServers and appear in Studio.

async toMCPServerProxies(): Promise<Record<string, MCPClientServerProxy>>

Spread the result into the mcpServers config on Mastra:

src/mastra/index.ts
import { Mastra } from '@mastra/core/mastra'
import { MCPClient } from '@mastra/mcp'

const mcpClient = new MCPClient({
servers: {
'color-mixer': {
command: 'node',
args: ['path/to/color-mixer-server.js'],
},
},
})

export const mastra = new Mastra({
mcpServers: {
...(await mcpClient.toMCPServerProxies()),
},
})

This is useful for connecting external MCP servers that implement the MCP Apps extension or other features to Studio without wrapping them in a Mastra MCPServer.

resources Property
Direct link to resources-property

The MCPClient instance has a resources property that provides access to resource-related operations.

const mcpClient = new MCPClient({/* ...servers configuration... */})

// Access resource methods via mcpClient.resources
const allResourcesByServer = await mcpClient.resources.list()
const templatesByServer = await mcpClient.resources.templates()
// ... and so on for other resource methods.

resources.list()
Direct link to resourceslist

Retrieves all available resources from all connected MCP servers, grouped by server name.

async list(): Promise<Record<string, Resource[]>>

Example:

const resourcesByServer = await mcpClient.resources.list()
for (const serverName in resourcesByServer) {
console.log(`Resources from ${serverName}:`, resourcesByServer[serverName])
}

resources.listWithErrors(options?)
Direct link to resourceslistwitherrorsoptions

Preserves successful resources while reporting failed servers through legacy string errors and structured errorDetails. Pass perServerTimeoutMs to bound each server independently and include durations.

const { resources, errors, errorDetails, durations } = await mcpClient.resources.listWithErrors({
perServerTimeoutMs: 3_000,
})

console.log(resources, errors, errorDetails, durations)

resources.templates()
Direct link to resourcestemplates

Retrieves all available resource templates from all connected MCP servers, grouped by server name.

async templates(): Promise<Record<string, ResourceTemplate[]>>

Example:

const templatesByServer = await mcpClient.resources.templates()
for (const serverName in templatesByServer) {
console.log(`Templates from ${serverName}:`, templatesByServer[serverName])
}

resources.templatesWithErrors(options?)
Direct link to resourcestemplateswitherrorsoptions

Returns successful resource templates together with per-server string errors, structured errorDetails, and optional durations.

const { templates, errors, errorDetails } = await mcpClient.resources.templatesWithErrors()
console.log(templates, errors, errorDetails)

resources.read(serverName: string, uri: string)
Direct link to resourcesreadservername-string-uri-string

Reads the content of a specific resource from a server.

async read(serverName: string, uri: string): Promise<ReadResourceResult>
  • serverName: The identifier of the server (key used in the servers constructor option).
  • uri: The URI of the resource to read.

Example:

const content = await mcpClient.resources.read('myWeatherServer', 'weather://current')
console.log('Current weather:', content.contents[0].text)

resources.subscribe(serverName: string, uri: string)
Direct link to resourcessubscribeservername-string-uri-string

Subscribes to updates for a specific resource on a server. Mastra adds the URI to a single managed subscriptions/listen stream per server. Subscribing to the same URI more than once has no effect while the stream is active. Mastra restores the stream after a reconnect and closes it when the client disconnects.

The method rejects if the server doesn't honor the requested URI. If stream restoration fails after a reconnect, the connection remains available and calling subscribe() again retries the stream.

async subscribe(serverName: string, uri: string): Promise<void>

Example:

await mcpClient.resources.subscribe('myWeatherServer', 'weather://current')

resources.unsubscribe(serverName: string, uri: string)
Direct link to resourcesunsubscribeservername-string-uri-string

Unsubscribes from updates for a specific resource on a server. Mastra removes the URI from the managed subscriptions/listen filter and replaces the stream. When nothing remains to listen for, Mastra closes the stream.

async unsubscribe(serverName: string, uri: string): Promise<void>

Example:

await mcpClient.resources.unsubscribe('myWeatherServer', 'weather://current')

resources.onUpdated(serverName: string, handler: (params: { uri: string }) => void)
Direct link to resourcesonupdatedservername-string-handler-params--uri-string---void

Sets a notification handler that will be called when a subscribed resource on a specific server is updated.

async onUpdated(serverName: string, handler: (params: { uri: string }) => void): Promise<void>

Example:

mcpClient.resources.onUpdated('myWeatherServer', params => {
console.log(`Resource updated on myWeatherServer: ${params.uri}`)
// You might want to re-fetch the resource content here
// await mcpClient.resources.read("myWeatherServer", params.uri);
})

resources.onListChanged(serverName: string, handler: () => void)
Direct link to resourcesonlistchangedservername-string-handler---void

Sets a notification handler that will be called when the list of available resources changes on a specific server.

async onListChanged(serverName: string, handler: () => void): Promise<void>

Example:

mcpClient.resources.onListChanged('myWeatherServer', () => {
console.log('Resource list changed on myWeatherServer.')
// You should re-fetch the list of resources
// await mcpClient.resources.list();
})

prompts Property
Direct link to prompts-property

The MCPClient instance has a prompts property that provides access to prompt-related operations.

const mcpClient = new MCPClient({/* ...servers configuration... */})

// Access prompt methods via mcpClient.prompts
const allPromptsByServer = await mcpClient.prompts.list()
const { prompt, messages } = await mcpClient.prompts.get({
serverName: 'myWeatherServer',
name: 'current',
})

prompts.list()
Direct link to promptslist

Retrieves all available prompts from all connected MCP servers, grouped by server name.

async list(): Promise<Record<string, Prompt[]>>

Example:

const promptsByServer = await mcpClient.prompts.list()
for (const serverName in promptsByServer) {
console.log(`Prompts from ${serverName}:`, promptsByServer[serverName])
}

prompts.listWithErrors(options?)
Direct link to promptslistwitherrorsoptions

Returns successful prompts together with per-server string errors, structured errorDetails, and optional durations.

const { prompts, errors, errorDetails } = await mcpClient.prompts.listWithErrors()
console.log(prompts, errors, errorDetails)

prompts.get({ serverName, name, args? })
Direct link to promptsget-servername-name-args-

Retrieves a specific prompt and its messages from a server.

async get({
serverName,
name,
args?,
}: {
serverName: string;
name: string;
args?: Record<string, any>;
}): Promise<GetPromptResult>

Example:

const { messages } = await mcpClient.prompts.get({
serverName: 'myWeatherServer',
name: 'current',
args: { location: 'London' },
})
console.log(messages)

prompts.onListChanged(serverName: string, handler: () => void)
Direct link to promptsonlistchangedservername-string-handler---void

Sets a notification handler that will be called when the list of available prompts changes on a specific server.

async onListChanged(serverName: string, handler: () => void): Promise<void>

Example:

mcpClient.prompts.onListChanged('myWeatherServer', () => {
console.log('Prompt list changed on myWeatherServer.')
// You should re-fetch the list of prompts
// await mcpClient.prompts.list();
})

tools Property
Direct link to tools-property

The MCPClient instance has a tools property for subscribing to tool list change notifications. To fetch tools, use listTools() or listToolsets().

tools.onListChanged(serverName: string, handler: () => void)
Direct link to toolsonlistchangedservername-string-handler---void

Sets a notification handler that will be called when the list of available tools changes on a specific server (for example, when the server adds or removes tools at runtime).

async onListChanged(serverName: string, handler: () => void): Promise<void>

Example:

await mcpClient.tools.onListChanged('myWeatherServer', async () => {
console.log('Tool list changed on myWeatherServer.')
// You should re-fetch the tools
// const tools = await mcpClient.listTools();
})

progress Property
Direct link to progress-property

The MCPClient instance has a progress property for subscribing to progress notifications emitted by MCP servers while tools execute.

const mcpClient = new MCPClient({
servers: {
myServer: {
url: new URL('http://localhost:4111/api/mcp/myServer/mcp'),
// Off by default: opt in so tool calls carry a progressToken
enableProgressTracking: true,
},
},
})

// Subscribe to progress updates for a specific server
await mcpClient.progress.onUpdate('myServer', params => {
console.log('📊 Progress:', params.progress, '/', params.total)
if (params.message) console.log('Message:', params.message)
if (params.progressToken) console.log('Token:', params.progressToken)
})

progress.onUpdate(serverName: string, handler)
Direct link to progressonupdateservername-string-handler

Registers a handler function to receive progress updates from the specified server.

async onUpdate(
serverName: string,
handler: (params: {
progressToken: string;
progress: number;
total?: number;
message?: string;
}) => void,
): Promise<void>

Notes:

  • When enableProgressTracking is true, tool calls include a progressToken so you can correlate updates to a specific run. Servers only send progress for requests that carry a token.
  • If you pass a runId when executing a tool, it will be used as the progressToken.

events Property (experimental)
Direct link to events-property-experimental

The events property lists and subscribes to server events from servers that implement the MCP Events extension (SEP-3415). Use it to react to things that happen on the server, such as a push to a repository, without polling a tool. Delivery is at least once. After a reconnect or a resume, the server can resend an occurrence, and the client drops repeats by eventId within dedupWindow of recent ids.

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

warning

SEP-3415 is a draft. This API may change in any release.

const events = await mcpClient.events.list('github')

const subscription = await mcpClient.events.subscribe(
'github',
'repo.push',
{ repo: 'mastra-ai/mastra' },
{ cursor: await loadCursor() },
)

for await (const event of subscription) {
await handlePush(event.data)
// Safe to persist after each handled occurrence
await saveCursor(subscription.cursor)
}

Servers that don't advertise the io.modelcontextprotocol/events extension receive no events/* requests. list() rejects with a MastraError whose id is MCP_CLIENT_LIST_EVENTS_FAILED, and subscribe() rejects with MCP_CLIENT_SUBSCRIBE_EVENT_FAILED. In both cases error.cause.id is MCP_EVENTS_NOT_SUPPORTED. Other list() and subscribe() failures use the same two ids, with the specific reason in error.cause.

events.list(serverName: string)
Direct link to eventslistservername-string

Returns every event type the server offers, following pagination. Each descriptor has a name, an optional description, the delivery modes the server supports, and optional inputSchema and payloadSchema.

async list(serverName: string): Promise<MCPEventDescriptor[]>

The client doesn't receive notifications when the server's event list changes yet. Call list() again to pick up new event types.

events.subscribe(serverName, eventName, args?, options?)
Direct link to eventssubscribeservername-eventname-args-options

Starts a subscription and resolves once the server confirms it. Errors such as an unknown event, invalid arguments, or a forbidden subscription reject the returned promise. A new subscription starts at the point the server accepts it, so occurrences from before that point aren't delivered unless options.cursor resumes from an earlier position. Under webhook delivery the promise can resolve after that point: the client answers 503 for a delivery it can't route yet, and the server retries it.

async subscribe(
serverName: string,
eventName: string,
args?: Record<string, unknown>,
options?: MCPEventSubscribeOptions,
): Promise<MCPEventSubscription>

The client picks the first mode the event offers in this order: webhook (only when the client has webhook settings), push (a long-lived events/stream request), then poll (repeated events/poll requests). Pass options.delivery to force a mode.

delivery?:

'webhook' | 'push' | 'poll'
Forces a delivery mode. When the event doesn't offer it, or for webhook without client webhook settings, subscribe() rejects with MCP_CLIENT_SUBSCRIBE_EVENT_FAILED and error.cause.id is MCP_EVENTS_NO_COMPATIBLE_DELIVERY_MODE or MCP_EVENTS_WEBHOOK_NOT_CONFIGURED.

cursor?:

string | null
Resumes from a cursor saved earlier. Omit or pass null to start from now.

maxAgeMs?:

number
Asks the server not to replay occurrences older than this.

maxEvents?:

number
Maximum occurrences per poll response. Poll mode only.

minPollIntervalMs?:

number
= 1000
Lower bound for the wait between polls, applied to the server-suggested nextPollMs.

heartbeatTimeoutMs?:

number
= 60000
Push mode reopens the stream from the last cursor after this long without any message from the server.

streamOpenTimeoutMs?:

number
How long to wait for the server to confirm a push stream. Defaults to the server request timeout.

dedupWindow?:

number
= 1000
Number of recent event ids remembered to drop duplicate occurrences.

maxBufferedEvents?:

number
= 1000
Most occurrences held while your loop isn't reading. When full, the oldest unread occurrence is dropped, the next one has truncated set, and onError receives MCP_EVENTS_BUFFER_OVERFLOW.

retry?:

{ initialDelayMs?: number; maxDelayMs?: number; factor?: number; maxAttempts?: number }
Backoff with full jitter for transient failures. Defaults to 1000 ms initial delay, 30000 ms maximum, factor 2, unlimited retries.

signal?:

AbortSignal
Aborting it cancels the subscription.

The returned MCPEventSubscription is an async iterable of occurrences (eventId, name, timestamp, data) with these members:

  • cursor: The latest position that's safe to persist. It never moves past an occurrence your loop hasn't received yet. It's null when the event type doesn't support replay.
  • truncated: true when the server reported a gap right before the occurrence your loop received last, for example because the saved cursor was older than its retention window. Read it inside the loop: it's true for the first occurrence after the gap and false for the ones after it.
  • mode: The delivery mode in use, 'webhook', 'push', or 'poll'.
  • deliveryStatus: Webhook mode only. The server's latest report on delivery to your webhook URL (active, lastDeliveryAt, lastError, failedSince), updated on each refresh.
  • onError(handler): Receives recoverable errors, such as a dropped stream that the client is reopening. Returns a function that removes the handler.
  • cancel(): Stops the subscription and ends the iterator. Breaking out of a for await loop also cancels it.

The iterator throws a MastraError directly (not wrapped) when the subscription can't continue: MCP_EVENTS_SUBSCRIPTION_FAILED for a non-retryable server error, MCP_EVENTS_SUBSCRIPTION_TERMINATED when the server ends a push or webhook subscription, or MCP_EVENTS_RETRIES_EXHAUSTED after retry.maxAttempts failed attempts in a row. The client reopens dropped streams and resumes polling from the last cursor after a reconnect, including after reconnectServer(). disconnect() ends all subscriptions.

events.handleWebhook(request: Request)
Direct link to eventshandlewebhookrequest-request

Handles one webhook delivery and returns the response to send back. Mount it on the route that webhook.url points at.

async handleWebhook(request: Request): Promise<Response>

It verifies the Standard Webhooks signature on the raw body before parsing it, answers the server's verification handshake, and routes occurrences to their subscription by the X-MCP-Subscription-Id header. It responds with:

  • 200 after the occurrence is handed to its subscription, or with the echoed challenge for a verification request.
  • 401 when the signature is invalid or the timestamp is more than 5 minutes old.
  • 503 when the subscription id isn't known yet. The server retries in that case, covering deliveries that arrive before the subscribe response.
  • 400 for a signed but malformed body, and 405 for methods other than POST.
  • 413 for a body over 1 MiB. The body is read only up to that limit, before any other check.
  • 404 when the client has no webhook settings.

Webhook delivery
Direct link to Webhook delivery

With webhook settings, the client registers each subscription with events/subscribe and the server POSTs occurrences to your URL. The server must authenticate the client, and the URL must be publicly reachable over https.

src/mastra/index.ts
import { Mastra } from '@mastra/core'
import { registerApiRoute } from '@mastra/core/server'
import { MCPClient } from '@mastra/mcp'

export const mcpClient = new MCPClient({
servers: {
github: {
url: new URL('https://mcp.example.com/mcp'),
requestInit: { headers: { Authorization: `Bearer ${process.env.GITHUB_MCP_TOKEN}` } },
},
},
webhook: { url: 'https://agent.example.com/mcp/webhook' },
})

export const mastra = new Mastra({
server: {
apiRoutes: [
registerApiRoute('/mcp/webhook', {
method: 'POST',
// Deliveries are authenticated by their signature, not by Mastra auth
requiresAuth: false,
handler: c => mcpClient.events.handleWebhook(c.req.raw),
}),
],
},
})

url:

string
Public https URL that routes to events.handleWebhook().

secret?:

string
A whsec_ signing secret used for every subscription. By default the client generates a fresh secret for each subscription, so one server can't sign deliveries for another server's subscriptions.

ttlMs?:

number | null
Suggested subscription lifetime. null asks for no expiry, which the server may decline. Defaults to the server default.

refreshMarginMs?:

number
How long before the server-granted refreshBefore time to refresh the subscription. Defaults to the smaller of 60 seconds and 20% of the grant.

The client refreshes each subscription before it expires by repeating events/subscribe with the latest cursor. When the server reports failing delivery in deliveryStatus, for example because your endpoint timed out, onError receives MCP_EVENTS_WEBHOOK_DELIVERY_FAILING with lastError in its details. The cursor in each webhook delivery only covers occurrences your endpoint has acknowledged, so resuming from a saved cursor can deliver the last handled occurrence again. The next refresh resumes a suspended subscription. cancel() sends events/unsubscribe.

Webhook state lives in this process: run the receiving route in the same process as the MCPClient that subscribed.

Webhook delivery needs an authenticated HTTP connection to the server, because the server ties each webhook subscription to the caller. Over stdio, or over HTTP without authentication, the server rejects events/subscribe. It also rejects new subscriptions once its webhook subscription cap is reached (-32025). A subscription that didn't force a mode then falls back to push or poll, and the first onError handler receives MCP_EVENTS_WEBHOOK_UNAVAILABLE with the rejection code and the fallback mode in its details. Forcing delivery: 'webhook' rejects instead.

Subscriptions on one server with the same event name and arguments share a single server-side webhook subscription. Each one gets every occurrence through its own iterator, and the client sends events/unsubscribe only when the last of them is cancelled.

Events in agents and workflows (experimental)
Direct link to Events in agents and workflows (experimental)

MCPEventsSignalProvider delivers MCP server events to agent threads as notifications. Add it to an agent's signals, then subscribe threads to events. The agent needs a notifications storage domain, which InMemoryStore and the standard storage adapters provide.

warning

SEP-3415 is a draft. This API may change in any release.

src/mastra/agents/repo-agent.ts
import { Agent } from '@mastra/core/agent'
import { MCPClient, MCPEventsSignalProvider } from '@mastra/mcp'

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

export const repoEvents = new MCPEventsSignalProvider({
client: mcp,
subscribeOptions: { maxAgeMs: 60 * 60 * 1000 },
})

export const repoAgent = new Agent({
id: 'repo-agent',
name: 'Repo Agent',
instructions: 'Triage repository activity.',
model: 'openai/gpt-6.1-sol',
signals: [repoEvents],
})

await repoEvents.subscribeThread(
{ resourceId: 'user-1', threadId: 'thread-1' },
{ server: 'github', event: 'repo.push', args: { repo: 'mastra-ai/mastra' } },
)

Threads that subscribe to the same server, event and arguments share one MCP subscription regardless of argument key order. Each occurrence becomes a notification with kind: 'mcp-event' and attributes: { server, event }. The raw occurrence is in payload. The notification summary, which is the only part the model reads, labels the event as untrusted and wraps its data, together with the server-advertised event name, in an <untrusted-mcp-event-data> block that the content can't close. When the server reports a gap, attributes.truncated is true and the summary says that events may have been skipped.

If the subscription ends, for example because the server terminates it, each subscribed thread receives a high priority notification with kind: 'mcp-event-subscription-ended' and the threads are unsubscribed.

client:

Pick<MCPClient, 'events'>
The client used to list and subscribe to events.

id?:

string
= 'mcp-events'
Provider id, used as the notification source.

name?:

string
= 'MCP Events'
Display name.

cursorStore?:

MCPEventCursorStore
= InMemoryMCPEventCursorStore
Stores the resume cursor for each subscription. Implement get(key), set(key, cursor) and delete(key) with durable storage so a subscription continues from its last cursor when a thread is subscribed again after a restart. Only cursors are stored, not which threads are subscribed. Only non-null cursors are stored.

subscribeOptions?:

Omit<MCPEventSubscribeOptions, 'cursor' | 'signal'>
Options for every MCP subscription, for example maxAgeMs to bound the backlog replayed on resume.

priority?:

NotificationPriority
= 'medium'
Priority of event notifications.

coalesce?:

boolean
= false
Sets coalesceKey: 'mcp-events:<key>' on event notifications, whatever the provider id. Pending notifications from one subscription then merge, so a burst that arrives before the agent reads its inbox keeps only the latest occurrence. When off, every occurrence is its own notification.

tools?:

boolean | { servers: string[] }
= false
Gives the agent the mcp_events_list, mcp_events_subscribe and mcp_events_unsubscribe tools, which act on the current thread and require memory. Event descriptions and error messages from the server reach the model inside the same untrusted-data block as event data. Subscriptions made through the tools are lost on restart. Pass servers to limit them to those servers.

onError?:

(error: unknown, info: { server: string; event: string; key: string }) => void
Receives delivery, cursor store and subscription errors.

The provider has these methods:

  • subscribeThread(thread, { server, event, args? }): Subscribes a thread and resolves once the MCP subscription is confirmed. It resumes from the stored cursor. If the server rejects the subscription, the thread isn't subscribed and the promise rejects. Thread subscriptions are kept in memory only: after a restart, call subscribeThread() again for each thread that should keep receiving events.
  • unsubscribeThread(thread, { server, event, args? }): Resolves to true when the thread was subscribed. When the last thread leaves, the MCP subscription is cancelled and its stored cursor is deleted.
  • stop(): Cancels every MCP subscription and keeps the stored cursors, so a new provider with the same cursorStore resumes where this one stopped.
  • MCPEventsSignalProvider.key({ server, event, args? }): Returns the subscription key used by the cursor store.

Delivery is at least once. The provider stores the cursor after it notifies the threads, so an occurrence notified right before a crash is replayed after the restart. The notification's dedupeKey includes the event id: if the first notification is still pending, the replay merges into it. If it was already delivered, the thread is notified again.

To start a workflow run for each occurrence, iterate a subscription directly:

src/mastra/triggers/repo-push.ts
const subscription = await mcp.events.subscribe(
'github',
'repo.push',
{ repo: 'mastra-ai/mastra' },
{ cursor: await loadCursor() },
)

process.once('SIGTERM', () => void subscription.cancel())

for await (const event of subscription) {
const run = await triagePushWorkflow.createRun()
await run.start({ inputData: { eventId: event.eventId, push: event.data } })
if (subscription.cursor) await saveCursor(subscription.cursor)
}

Answering input requests
Direct link to Answering input requests

A server tool, resource or prompt that needs something from the user ends its call with an input_required result instead of a value. The result carries one or more embedded requests, each with a server-chosen key and either a form (requestedSchema) or a URL the user must visit. Configure inputRequests on the server definition to answer them: the client calls the handler once per embedded request, retries the original call with the answers, and resolves the tool call with the final result. Without a handler an input_required result surfaces as an error.

import { MCPClient } from '@mastra/mcp'

const mcpClient = new MCPClient({
servers: {
interactiveServer: {
url: new URL('http://localhost:3000/mcp'),
inputRequests: async ({ key, params, signal }) => {
if (params.mode === 'url') {
return { action: 'decline' }
}
const content = await collectUserInput(params.message, params.requestedSchema, { signal })
return { action: 'accept', content }
},
},
},
})

MCPInputRequest
Direct link to mcpinputrequest

The handler receives:

  • key: The server-chosen key the answer is filed under. Pass it through to your UI when several requests arrive in one round.
  • params: The request. In form mode it has message and requestedSchema, a flat JSON Schema object of primitive fields. In url mode it has message and url.
  • signal: Aborts when the originating call is cancelled or times out.

Configuring a handler advertises form support to the server. To accept url-mode requests as well, widen capabilities.elicitation on the same server definition.

Response types
Direct link to Response types

Return an ElicitResult:

  • Accept: The user provided data and confirmed submission. content must match requestedSchema.

    return {
    action: 'accept',
    content: { name: 'John Doe', email: 'john@example.com' },
    }
  • Decline: The user explicitly declined to provide the information.

    return { action: 'decline' }
  • Cancel: The user dismissed the request.

    return { action: 'cancel' }

A declined or cancelled request ends the tool call with an error. A server can ask again after an accepted answer, so the handler may run several times for one tool call.

Schema-based input collection
Direct link to Schema-based input collection

Walk requestedSchema to prompt for each field:

inputRequests: async ({ params }) => {
if (params.mode === 'url') return { action: 'decline' }

const { properties, required = [] } = params.requestedSchema
const content: Record<string, string | number | boolean> = {}

for (const [fieldName, fieldSchema] of Object.entries(properties)) {
const value = await promptUser({
name: fieldName,
title: fieldSchema.title,
description: fieldSchema.description,
required: required.includes(fieldName),
})

if (value !== null) {
content[fieldName] = value
}
}

return { action: 'accept', content }
}

Best practices
Direct link to Best practices

  • Configure the handler up front: it's part of the server definition, so a tool that asks for input never runs without one.
  • Validate input: Check that required fields are provided.
  • Respect user choice: Handle decline and cancel responses gracefully.
  • Clear UI: Make it obvious what information is being requested and why.
  • Security: Never auto-accept requests for sensitive information, and treat url-mode requests as links to untrusted sites.

OAuth authentication
Direct link to OAuth authentication

For connecting to MCP servers that require OAuth authentication per the MCP 2026-07-28 authorization specification, use the MCPOAuthClientProvider. The provider never registers a client at runtime: give it either clientInformation for a client pre-registered with the authorization server, or a Client ID Metadata Document URL as clientMetadataUrl:

import { MCPClient, MCPOAuthClientProvider } from '@mastra/mcp'

// Create an OAuth provider
const clientMetadataUrl = 'https://app.example.com/oauth/client-metadata.json'

const oauthProvider = new MCPOAuthClientProvider({
redirectUrl: 'http://localhost:3000/oauth/callback',
clientMetadataUrl,
clientMetadata: {
client_id: clientMetadataUrl,
redirect_uris: ['http://localhost:3000/oauth/callback'],
client_name: 'My MCP Client',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
},
onRedirectToAuthorization: url => {
// Handle authorization redirect (open browser, redirect response, etc.)
console.log(`Please visit: ${url}`)
},
})

// Use the provider with MCPClient
const client = new MCPClient({
servers: {
protectedServer: {
url: new URL('https://mcp.example.com/mcp'),
authProvider: oauthProvider,
},
},
})

The document served at clientMetadataUrl must contain the same client_id, client_name, and redirect_uris. For loopback callbacks, include every fallback URL returned by getCallbackUrlCandidates() in the hosted document. The URL is sent as the client_id; the provider never calls a registration endpoint, so an authorization server that accepts neither the metadata document nor a pre-registered clientInformation fails the flow explicitly. Configure exactly one identity: the constructor throws when both clientInformation and clientMetadataUrl are set.

Tokens saved by MCPOAuthClientProvider are bound to the authorization server's validated issuer, and discovery state is persisted so the code exchange is only sent to the server that issued the redirect. The loopback callback also forwards the RFC 9207 iss parameter to the SDK, which rejects a mismatch before exchanging the authorization code.

Give each server its own MCPOAuthClientProvider instance. A provider holds per-server session and credential state during authorization, so sharing one instance across multiple servers lets their flows overwrite each other. When configuring several protected servers, construct a separate provider for each. If those providers use the same persistent backend, give each provider a separate OAuthStorage namespace because storage mutation ordering is coordinated only within one provider instance.

Interactive browser authentication
Direct link to Interactive browser authentication

When a server rejects a connection because authorization is required, the client records a 'needs-auth' state instead of failing outright. Calling authenticate() completes the flow. It starts a one-shot callback server on the provider's loopback redirect URL, falling back to the next sequential ports when it's in use. The SDK then performs discovery and uses the configured client identity. onRedirectToAuthorization receives the authorization URL so your application can open it in the user's browser. The token exchange finishes after the browser returns the authorization code:

import { MCPClient, MCPOAuthClientProvider } from '@mastra/mcp'

const clientMetadataUrl = 'https://app.example.com/oauth/client-metadata.json'

const oauthProvider = new MCPOAuthClientProvider({
redirectUrl: 'http://127.0.0.1:5533/oauth/callback',
clientMetadataUrl,
clientMetadata: {
client_id: clientMetadataUrl,
redirect_uris: ['http://127.0.0.1:5533/oauth/callback'],
client_name: 'My MCP Client',
grant_types: ['authorization_code', 'refresh_token'],
response_types: ['code'],
},
onRedirectToAuthorization: url => {
// Open the user's browser at the consent page
console.log(`Please visit: ${url}`)
},
})

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

try {
await mcp.listTools()
} catch {
if (mcp.getServerAuthState('protectedServer') === 'needs-auth') {
await mcp.authenticate('protectedServer')
}
}

Concurrent authenticate() calls for the same server join the pending flow. Different servers authenticate independently. With valid stored tokens the call reconnects without opening a browser.

Hosts that drive the flow can capture the authorization code with the exported createOAuthCallbackServer helper. It creates a one-shot loopback server that validates the OAuth state parameter before resolving with the code. Because the server uses plain HTTP, use it only for local loopback redirects. Web applications with an HTTPS redirect URL must host their own callback endpoint and drive the provider directly:

import { createOAuthCallbackServer, getCallbackUrlCandidates } from '@mastra/mcp'

// getCallbackUrlCandidates() lists every URL the helper may bind, so list all
// of them as redirect_uris in your pre-registration or metadata document.
const redirectUris = getCallbackUrlCandidates('http://127.0.0.1:5533/oauth/callback').map(url =>
url.toString(),
)

const server = await createOAuthCallbackServer({
redirectUrl: 'http://127.0.0.1:5533/oauth/callback',
state: expectedState,
})

// server.url reflects the port actually bound — use it as the redirect_uri.
try {
const { code, iss } = await server.waitForCode()
// Exchange the code here, passing `iss` so the SDK validates the issuer.
} finally {
await server.close()
}

Quick Token Provider
Direct link to Quick Token Provider

For testing or when you already have a valid access token:

import { MCPClient, createSimpleTokenProvider } from '@mastra/mcp'

const provider = createSimpleTokenProvider('your-access-token', {
redirectUrl: 'http://localhost:3000/callback',
clientMetadata: {
redirect_uris: ['http://localhost:3000/callback'],
client_name: 'Test Client',
},
clientInformation: { client_id: 'test-client' },
})

const client = new MCPClient({
servers: {
testServer: {
url: new URL('https://mcp.example.com/mcp'),
authProvider: provider,
},
},
})

Custom Token Storage
Direct link to Custom Token Storage

For persistent token storage across sessions, implement the OAuthStorage interface. The provider stores tokens (both the latest set and one entry per authorization-server issuer), discovery state, and the PKCE verifier under string keys. The backend only needs a key-value contract:

import { MCPOAuthClientProvider, OAuthStorage } from '@mastra/mcp'

class DatabaseOAuthStorage implements OAuthStorage {
constructor(
private db: Database,
private userId: string,
) {}

async set(key: string, value: string): Promise<void> {
await this.db.query(
'INSERT INTO oauth_tokens (user_id, key, value) VALUES (?, ?, ?) ON CONFLICT DO UPDATE SET value = ?',
[this.userId, key, value, value],
)
}

async get(key: string): Promise<string | undefined> {
const result = await this.db.query(
'SELECT value FROM oauth_tokens WHERE user_id = ? AND key = ?',
[this.userId, key],
)
return result?.[0]?.value
}

async delete(key: string): Promise<void> {
await this.db.query('DELETE FROM oauth_tokens WHERE user_id = ? AND key = ?', [
this.userId,
key,
])
}
}

const provider = new MCPOAuthClientProvider({
redirectUrl: 'http://localhost:3000/callback',
clientMetadata: {/* ... */},
clientInformation: { client_id: 'my-registered-client' },
storage: new DatabaseOAuthStorage(db, 'user-123'),
})

Examples
Direct link to Examples

Static Tool Configuration
Direct link to Static Tool Configuration

For tools where you have a single connection to the MCP server for you entire app, use listTools() and pass the tools to your agent:

import { MCPClient } from '@mastra/mcp'
import { Agent } from '@mastra/core/agent'

const mcp = new MCPClient({
servers: {
stockPrice: {
command: 'npx',
args: ['tsx', 'stock-price.ts'],
env: {
API_KEY: 'your-api-key',
},
logger: logMessage => {
console.log(`[${logMessage.level}] ${logMessage.message}`)
},
},
weather: {
url: new URL('http://localhost:8080/mcp'),
},
},
timeout: 30000, // Global 30s timeout
})

// Create an agent with access to all tools
const agent = new Agent({
id: 'multi-tool-agent',
name: 'Multi-tool Agent',
instructions: 'You have access to multiple tool servers.',
model: 'openai/gpt-6.1-sol',
tools: await mcp.listTools(),
})

// Example of using resource methods
async function checkWeatherResource() {
try {
const weatherResources = await mcp.resources.list()
if (weatherResources.weather && weatherResources.weather.length > 0) {
const currentWeatherURI = weatherResources.weather[0].uri
const weatherData = await mcp.resources.read('weather', currentWeatherURI)
console.log('Weather data:', weatherData.contents[0].text)
}
} catch (error) {
console.error('Error fetching weather resource:', error)
}
}
checkWeatherResource()

// Example of using prompt methods
async function checkWeatherPrompt() {
try {
const weatherPrompts = await mcp.prompts.list()
if (weatherPrompts.weather && weatherPrompts.weather.length > 0) {
const currentWeatherPrompt = weatherPrompts.weather.find(p => p.name === 'current')
if (currentWeatherPrompt) {
console.log('Weather prompt:', currentWeatherPrompt)
} else {
console.log('Current weather prompt not found')
}
}
} catch (error) {
console.error('Error fetching weather prompt:', error)
}
}
checkWeatherPrompt()

Dynamic toolsets
Direct link to Dynamic toolsets

When you need a new MCP connection for each user, use listToolsets() and add the tools when calling stream or generate:

import { Agent } from '@mastra/core/agent'
import { MCPClient } from '@mastra/mcp'

// Create the agent first, without any tools
const agent = new Agent({
id: 'multi-tool-agent',
name: 'Multi-tool Agent',
instructions: 'You help users check stocks and weather.',
model: 'openai/gpt-6.1-sol',
})

// Later, configure MCP with user-specific settings
const mcp = new MCPClient({
servers: {
stockPrice: {
command: 'npx',
args: ['tsx', 'stock-price.ts'],
env: {
API_KEY: 'user-123-api-key',
},
timeout: 20000, // Server-specific timeout
},
weather: {
url: new URL('http://localhost:8080/mcp'),
requestInit: {
headers: {
Authorization: `Bearer user-123-token`,
},
},
},
},
})

// Pass all toolsets to stream() or generate()
const response = await agent.stream('How is AAPL doing and what is the weather?', {
toolsets: await mcp.listToolsets(),
})

Instance management
Direct link to Instance management

The MCPClient class includes built-in memory leak prevention for managing multiple instances:

  1. Creating multiple instances with identical configurations without an id will throw an error to prevent memory leaks
  2. If you need multiple instances with identical configurations, provide a unique id for each instance
  3. Call await configuration.disconnect() before recreating an instance with the same configuration
  4. If you only need one instance, consider moving the configuration to a higher scope to avoid recreation

For example, if you try to create multiple instances with the same configuration without an id:

// First instance - OK
const mcp1 = new MCPClient({
servers: {/* ... */},
})

// Second instance with same config - Will throw an error
const mcp2 = new MCPClient({
servers: {/* ... */},
})

// To fix, either:
// 1. Add unique IDs
const mcp3 = new MCPClient({
id: 'instance-1',
servers: {/* ... */},
})

// 2. Or disconnect before recreating
await mcp1.disconnect()
const mcp4 = new MCPClient({
servers: {/* ... */},
})

Server lifecycle
Direct link to Server lifecycle

MCPClient handles server connections gracefully:

  1. Automatic connection management for multiple servers
  2. Graceful server shutdown to prevent error messages during development
  3. Proper cleanup of resources when disconnecting

Handle shutdown yourself
Direct link to Handle shutdown yourself

By default, a connected client installs hooks for SIGINT, SIGTERM, SIGHUP, and beforeExit that disconnect its servers and end the process. These hooks can interrupt your application's awaited cleanup, such as draining HTTP requests.

Set registerProcessHooks: false to let your application own shutdown. Call disconnect() after requests that use the client have finished:

import { MCPClient } from '@mastra/mcp'

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

process.once('SIGTERM', async () => {
// Finish your application's cleanup before disconnecting the client.
await mcp.disconnect()
process.exit(0)
})

Set the option on every MCPClient in the process before connecting. Once any client registers the default hooks, the global SIGINT and SIGTERM listeners remain until the process exits, even after disconnect().

With hooks disabled, your application is responsible for closing MCP connections. In a Mastra server, coordinate disconnect() with your server's shutdown lifecycle so in-flight requests can finish using the client.

Using custom fetch for runtime-defined authentication
Direct link to Using custom fetch for runtime-defined authentication

For HTTP servers, you can provide a custom fetch function to handle runtime-defined authentication or request interception. It can also handle other custom behavior. This is particularly useful when you need to refresh tokens on each request or forward user credentials from the incoming request to the MCP server.

The custom fetch function receives an optional third requestContext parameter, which provides access to request-scoped data (e.g., authentication cookies, bearer tokens) set by middleware or passed during agent/tool execution. The requestContext is null during the initial connection handshake.

When fetch is provided, requestInit and authProvider become optional, as you can handle these concerns within your custom fetch function.

const mcpClient = new MCPClient({
servers: {
apiServer: {
url: new URL('https://api.example.com/mcp'),
fetch: async (url, init, requestContext) => {
const headers = new Headers(init?.headers)
// Forward auth cookie from the incoming request
const cookie = requestContext?.get('cookie')
if (cookie) {
headers.set('cookie', cookie)
}
return fetch(url, { ...init, headers })
},
},
},
})

// Use with an agent — requestContext is automatically forwarded
const agent = new Agent({
id: 'my-agent',
name: 'My Agent',
instructions: 'You are a helpful assistant.',
model: openai('gpt-5.4'),
tools: await mcpClient.listTools(),
})

await agent.generate('Hello!', {
requestContext: myRequestContext, // forwarded to the custom fetch
})

Handling auth failures inside custom fetch
Direct link to Handling auth failures inside custom fetch

A custom fetch shouldn't throw when authentication is unavailable. Every MCP request is a POST, and the SDK surfaces a non-2xx response as an error to the caller of listTools(), tools/call and so on, which is the behavior you want. A thrown fetch is reported the same way but loses the server's status and body. The only long-lived request is the subscriptions/listen POST the client opens for resources.subscribe(), and it's retried after a failure, so an unauthenticated stream can loop. Wait for the token, then forward the request:

async function waitForToken(timeoutMs = 5000): Promise<string | null> {
// Replace with your token lookup. Return null if no token is available.
return getAuthToken({ timeoutMs })
}

const mcpClient = new MCPClient({
servers: {
apiServer: {
url: new URL('https://api.example.com/mcp'),
fetch: async (url, init) => {
const token = await waitForToken()
if (!token) {
// Forward the request without a token and let the server reject it.
return fetch(url, init)
}

const headers = new Headers(init?.headers)
headers.set('authorization', `Bearer ${token}`)
return fetch(url, { ...init, headers })
},
},
},
})

Sending request headers
Direct link to Sending request headers

Static headers go in requestInit. They're sent on every request, including the subscriptions/listen stream:

const client = new MCPClient({
servers: {
exampleServer: {
url: new URL('https://your-mcp-server.com/mcp'),
requestInit: {
headers: {
Authorization: 'Bearer your-token',
},
},
},
},
})

Use a custom fetch when the value changes per request.