Migrate @mastra/mcp from v1 to v2
@mastra/mcp 2.0 serves the MCP 2026-07-28 revision only. Servers no longer negotiate older protocol revisions. Every request is self-contained: the initialize handshake, session header, standalone HTTP+SSE transport and server-initiated requests are gone. A tool, resource or prompt that needs input from the caller calls suspend(). The server answers with a native input_required continuation and runs the handler again with the caller's answer in resumeData.
The client speaks 2026-07-28 and, by default, probes each server and speaks whichever revision it offers, so one MCPClient can reach upgraded and third-party servers alike. Features that older revisions lack fail with an explicit error on a legacy-negotiated connection instead of being emulated.
If you need to keep serving pre-2026 clients, stay on @mastra/mcp 1.x. It remains supported against current @mastra/core, and a Mastra instance can register 1.x and 2.x servers side by side.
Streamable HTTP still streams responses as Server-Sent Events. What's removed is the standalone GET /sse + POST /messages transport, not SSE framing.
ChangedDirect link to Changed
@mastra/core peer rangeDirect link to mastracore-peer-range
@mastra/mcp 2.0 requires @mastra/core 1.68 or newer, which adds the shared server contract (mcpVersion, MCPToolExecutionResultV2, context.mcp.protocolVersion). Update both packages together.
- "@mastra/core": "^1.60.0",
- "@mastra/mcp": "^1.17.0"
+ "@mastra/core": "^1.68.0",
+ "@mastra/mcp": "^2.0.0"
Tools that ask the caller for input suspend and resumeDirect link to Tools that ask the caller for input suspend and resume
In 1.x a tool asked for input through context.mcp.elicitation.sendRequest() and awaited the answer while the request stayed open. In 2.0 the tool calls context.suspend(payload) and returns; the server ends the request as input_required, and when the caller answers, the tool runs again with context.resumeData (the answer, validated against resumeSchema) and context.suspendPayload (what it suspended with, validated against suspendSchema). This is the same suspend/resume vocabulary agents and workflows already use, so one createTool definition serves all three.
To migrate, move each sendRequest into a suspension. Put the state the next round needs in the suspend payload and branch on it when the tool resumes. The server never replays earlier rounds: each round sees only the previous payload and the current answer.
export const bookDelivery = createTool({
id: 'bookDelivery',
inputSchema: z.object({ orderId: z.string() }),
outputSchema: z.object({ confirmed: z.boolean() }),
+ suspendSchema: z.object({ phase: z.literal('address'), message: z.string() }),
+ resumeSchema: z.object({ address: z.string() }),
execute: async ({ orderId }, context) => {
- const answer = await context.mcp!.elicitation.sendRequest({
- message: 'Delivery address?',
- requestedSchema: addressSchema,
- });
- if (answer.action !== 'accept') return { confirmed: false };
- await book(orderId, answer.content.address);
- return { confirmed: true };
+ if (!context.resumeData) {
+ await context.suspend?.({ phase: 'address', message: 'Delivery address?' });
+ return;
+ }
+ await book(orderId, context.resumeData.address);
+ return { confirmed: true };
},
});
resumeSchema becomes the form the caller fills in, so it must describe a flat object of primitives. A caller that declines or cancels the form ends the call with an error, and the tool doesn't run again.
The continuation travels as an opaque requestState string that the client echoes byte for byte. The server signs it, and rejects a tampered, expired or foreign state (a different tool, different arguments or a different caller) before your handler runs. The caller is the token subject when the authorization layer provides one, otherwise the id of the user mapAuthInfoToUser returns, otherwise the bearer token itself, so two users behind the same OAuth client can't resume each other's rounds. On a server without authorization every caller shares one anonymous principal, so a requestState behaves like a bearer credential until its ttlSeconds expire: anyone who obtains it can answer the round. Put tools whose suspensions carry authority (writes, purchases, account changes) behind authorization. The payload is signed, not encrypted, so keep it small and non-secret (IDs and phase, not confidential data). Set requestState: { key } from the environment so every instance that may answer a continuation shares the key. Without it the server generates a key per process and continuations only succeed on that process.
const server = new MCPServer({
name: 'booking',
version: '2.0.0',
tools: { bookDelivery },
+ requestState: { key: process.env.MCP_REQUEST_STATE_KEY!, ttlSeconds: 600 },
});
context.mcp is the same object, with server-initiated requests removedDirect link to contextmcp-is-the-same-object-with-server-initiated-requests-removed
Tools still receive context.mcp on a 2.0 server: extra (cancellation signal, requestId, authInfo, _meta), log and progress work as before, and context.mcp.protocolVersion is '2026-07-28'. The members that relied on server-initiated requests are deprecated and throw on a 2.0 server with a message that names the replacement: elicitation.sendRequest (use suspend), extra.sendRequest and extra.sendNotification (use log and progress).
executeTool reports a suspensionDirect link to executetool-reports-a-suspension
server.executeTool() and the Mastra REST route POST /api/mcp/:serverId/tools/:toolId/execute return { status: 'completed', output } for a finished call and { status: 'suspended', suspendPayload, resumeSchema } when the tool asked for input. Continue by posting the same data again with resumeData and the echoed suspendPayload. Invalid resumeData rejects the call.
- const output = await server.executeTool('bookDelivery', { orderId });
+ const result = await server.executeTool('bookDelivery', { orderId });
+ if (result.status === 'suspended') {
+ const answer = await askUser(result.suspendPayload, result.resumeSchema);
+ await server.executeTool('bookDelivery', { orderId }, { resumeData: answer, suspendPayload: result.suspendPayload });
+ }
Resource and prompt callbacks can suspend tooDirect link to Resource and prompt callbacks can suspend too
getResourceContent and getPromptMessages receive { extra, requestContext, suspend, resumeData, suspendPayload } alongside their existing parameters. extra is the same protocol context tools see as context.mcp.extra, and requestContext is the trusted application context that already carries authInfo and the user mapped by mapAuthInfoToUser. Declare resumeSchema on resources or prompts to make suspend usable.
resources: {
listResources: async ({ requestContext }) => listFor(requestContext.get('authInfo')?.clientId),
- getResourceContent: async ({ uri, extra }) => read(uri, extra?.signal),
+ resumeSchema: z.object({ reader: z.string() }),
+ getResourceContent: async ({ uri, extra, suspend, resumeData }) => {
+ if (!resumeData) return suspend({ message: 'Who is reading?' });
+ return read(uri, resumeData.reader, extra.signal);
+ },
},
Per-request logging replaces session log levelsDirect link to Per-request logging replaces session log levels
Servers no longer accept logging/setLevel or keep a log level per connection. A client opts in per request by sending the io.modelcontextprotocol/logLevel metadata key, and the server delivers notifications/message for that request only, filtered to the requested severity. A later round of the same tool call is a new request: it must opt in again. Tools keep logging through context.mcp.log(level, message, data). The Mastra logger and observability are unaffected.
On the client, enableServerLogs (default true) attaches the metadata key to every request at serverLogLevel (default 'info'). Set enableServerLogs: false to receive nothing. Delivered messages still reach your logger handler.
servers: {
weather: {
url: new URL('http://localhost:4111/api/mcp/weather/mcp'),
enableServerLogs: true,
+ serverLogLevel: 'warning',
logger: msg => console.log(msg.serverName, msg.level, msg.message),
},
},
Resource subscriptions keep their API and ride one listen streamDirect link to Resource subscriptions keep their API and ride one listen stream
resources.subscribe and resources.unsubscribe keep their signatures. Under the hood the client carries every subscription and list-changed handler on a single subscriptions/listen stream per server, replaces the stream when the set changes, and reopens it after a reconnect. Register handlers before subscribing so nothing is missed. A subscription the server declines rejects and leaves earlier subscriptions in place.
await mcp.resources.onUpdated('weather', ({ uri }) => refresh(uri))
await mcp.resources.subscribe('weather', 'weather://forecast')
// later
await mcp.resources.unsubscribe('weather', 'weather://forecast')
Client input handlers are configured per serverDirect link to Client input handlers are configured per server
The client answered server elicitation with mcp.elicitation.onRequest(serverName, handler). Because input requests are now embedded in input_required results, the handler is part of the server definition and receives one request at a time. Configuring it advertises the elicitation.form capability. Without a handler an input_required result is surfaced as an error rather than answered on your behalf.
const mcp = new MCPClient({
servers: {
booking: {
url: new URL('http://localhost:4111/api/mcp/booking/mcp'),
+ inputRequests: async ({ key, params }) => askUser(key, params),
},
},
});
- await mcp.elicitation.onRequest('booking', async params => askUser(params));
Client protocolVersion pins instead of selecting a revisionDirect link to client-protocolversion-pins-instead-of-selecting-a-revision
The 1.x client accepted protocolVersion: '2025-11-25' | '2026-07-28' | 'auto'. The 2.0 client always speaks 2026-07-28 and, when the option is omitted, probes the server with server/discover and falls back to the initialize handshake for servers that haven't upgraded. Pin '2026-07-28' to skip the probe and fail on a legacy server, or 'legacy' to skip the probe and use the handshake directly. The negotiated revision is cached per connection for reconnects and reported by mcp.getServerProtocolVersions().
On a legacy-negotiated connection the shared verbs work (tools/list, tools/call, resources/read, prompts/get). Resource subscriptions, list-changed handlers and embedded input requests throw an error naming the negotiated revision.
servers: {
thirdParty: {
url: new URL('https://example.com/mcp'),
- protocolVersion: 'auto',
+ protocolVersion: 'legacy', // optional: skip the probe for a server known not to have upgraded
},
},
OAuth clients are pre-registered or use a Client ID Metadata DocumentDirect link to OAuth clients are pre-registered or use a Client ID Metadata Document
MCPOAuthClientProvider no longer registers clients dynamically. Pass either clientInformation for a client you registered with the authorization server, or clientMetadataUrl (SEP-991) so the server fetches your client metadata from an HTTPS URL. Constructing the provider with neither throws, and no request is ever sent to a registration_endpoint.
const authProvider = new MCPOAuthClientProvider({
redirectUrl: 'http://localhost:3000/oauth/callback',
clientMetadata: { client_name: 'My Agent', redirect_uris: ['http://localhost:3000/oauth/callback'] },
+ clientInformation: { client_id: process.env.MCP_OAUTH_CLIENT_ID! },
});
Persisted credentials changed shape too: tokens are stored per authorization-server issuer (the SDK's tokens(ctx)/saveTokens(tokens, ctx) context) and the provider persists OAuth discovery state so the authorization code is only exchanged with the server that issued the redirect. Custom OAuthStorage backends keep the same key-value contract, but a storage namespace must not be shared between providers. createOAuthCallbackServer now also returns the RFC 9207 iss parameter. Pass it to the code exchange so the SDK can reject an issuer mismatch.
startHTTP optionsDirect link to starthttp-options
startHTTP keeps url, httpPath, req and res. The options object only carries request security (enableDnsRebindingProtection, allowedHosts, allowedOrigins). The session and serverless flags are gone because every request is stateless.
await server.startHTTP({
url: new URL(req.url!, 'http://localhost'),
httpPath: '/mcp',
req,
res,
- options: { sessionIdGenerator: () => randomUUID(), serverless: false },
+ options: { enableDnsRebindingProtection: true, allowedHosts: ['localhost:4111'] },
});
Docs server Prompt metadataDirect link to docs-server-prompt-metadata
MastraPrompt and its deprecated version field are gone; prompt providers return the SDK Prompt type, re-exported from @mastra/mcp.
- import type { MastraPrompt } from '@mastra/mcp';
+ import type { Prompt } from '@mastra/mcp';
RemovedDirect link to Removed
Server protocolVersion optionDirect link to server-protocolversion-option
Servers accepted protocolVersion ('2025-11-25', '2026-07-28' or 'auto'). Only 2026-07-28 is served now, exposed as the MCP_PROTOCOL_VERSION constant. Remove the option; a client that doesn't offer 2026-07-28 fails with an explicit negotiation error instead of a downgrade.
const server = new MCPServer({
name: 'weather',
version: '1.0.0',
- protocolVersion: '2026-07-28',
tools,
});
startSSE, startHonoSSE, connectSSE and the /sse + /messages routesDirect link to startsse-starthonosse-connectsse-and-the-sse--messages-routes
The standalone HTTP+SSE transport is no longer served, and the client no longer falls back to it when Streamable HTTP is unavailable. startSSE and startHonoSSE remain on the shared MCPServerBase for 1.x servers but reject on a 2.0 server. connectSSE is removed. Mastra server adapters answer 404 on /sse and /messages for 2.0 servers, and Studio no longer shows an SSE endpoint for them. Point every client at the /mcp endpoint.
- url: new URL('http://localhost:4111/api/mcp/weather/sse'),
+ url: new URL('http://localhost:4111/api/mcp/weather/mcp'),
handleServerlessRequest, sessionId, sessionIds, reconnectionOptions, eventSourceInitDirect link to handleserverlessrequest-sessionid-sessionids-reconnectionoptions-eventsourceinit
Requests are stateless, so there is nothing to resume or identify. startHTTP handles serverless and long-lived servers alike. Remove the session options from server and client definitions.
servers: {
weather: {
url: new URL('http://localhost:4111/api/mcp/weather/mcp'),
- sessionId: savedSessionId,
- reconnectionOptions: { maxRetries: 3 },
},
},
elicitation actions on server and clientDirect link to elicitation-actions-on-server-and-client
server.elicitation.sendRequest() and mcp.elicitation.onRequest() are removed. Suspend from the tool and configure inputRequests on the client.
roots and samplingDirect link to roots-and-sampling
Clients no longer advertise roots. setRoots() and sendRootsListChanged() are gone. Servers neither request sampling nor advertise it. A server that embeds a roots/list or sampling/createMessage request in input_required isn't answered: the client has no handler for those methods, so the call fails instead of fabricating a response.
logging/setLevel and sendLoggingMessageDirect link to loggingsetlevel-and-sendloggingmessage
Servers keep the static logging capability required by the specification but reject logging/setLevel with method-not-found. server.sendLoggingMessage() and server.getServer() are removed. Log per request through context.mcp.log or keep using the Mastra logger.
resources/subscribe and resources/unsubscribeDirect link to resourcessubscribe-and-resourcesunsubscribe
Legacy resources/subscribe is removed from both sides. resources.subscribe now uses subscriptions/listen.
Dynamic client registrationDirect link to Dynamic client registration
registerClient, OAuthClientRegistrationError and the saveClientInformation-driven registration fallback are removed. See the OAuth section above for the replacement.
Telling 1.x and 2.0 servers apartDirect link to Telling 1.x and 2.0 servers apart
Both extend the same MCPServerBase, and a 2.0 server sets mcpVersion to 2. Use it to branch where the two differ, such as whether an executeTool result can be a suspension.
const server = mastra.getMCPServer('booking');
+ if (server?.mcpVersion === 2) { ... }