Connect to OAuth-protected MCP servers

Give a Mastra agent tools from hosted MCP servers and complete the OAuth flow on deploy using your agent's public URL
View as Markdown

Hosted MCP servers expose their tools over OAuth 2.1. A Mastra agent can use them directly: MCPClient pulls the tools, and @mastra/mcp’s MCPOAuthClientProvider handles dynamic client registration (RFC 7591) and PKCE for you.

The one part that trips people up on a deployed agent is the redirect URI. After a user approves access, the OAuth provider sends their browser to that URL with an authorization code, so it has to resolve back to your agent. Locally that’s http://localhost, but in production it needs to be your agent’s public hostname. Astro injects exactly that as ASTRO_EXTERNAL_AGENT_URL, so you can forge the redirect at runtime and complete the flow hands-free.

This guide shows the full setup end to end.

Examples use @mastra/mcp. The same principles apply to any MCP client, only the provider API differs.

1. Connect the MCP client

Point MCPClient at the hosted server’s URL and give it an OAuth provider:

agent/index.ts
1import { Agent } from "@mastra/core/agent";
2import { MCPClient, MCPOAuthClientProvider } from "@mastra/mcp";
3import { serve } from "@astropods/adapter-mastra";
4
5const provider = new MCPOAuthClientProvider({
6 redirectUrl, // resolved in step 2
7 clientMetadata: {
8 client_name: "My Agent",
9 redirect_uris: [redirectUrl],
10 grant_types: ["authorization_code", "refresh_token"],
11 response_types: ["code"],
12 token_endpoint_auth_method: "none", // public client + PKCE
13 },
14 storage: tokenStore, // step 3
15});
16
17const mcp = new MCPClient({
18 servers: {
19 myServer: { url: new URL("https://mcp.example.com/mcp"), authProvider: provider },
20 },
21});
22
23const agent = new Agent({
24 name: "My Agent",
25 model: "anthropic/claude-sonnet-4-5",
26 instructions: "Answer questions using the connected tools.",
27 tools: await mcp.listTools(), // namespaced as <server>_<tool>
28});
29
30serve(agent);

listTools() skips servers that aren’t authorized yet (it logs and moves on), so your agent still boots before any OAuth has happened.

2. Forge the redirect from your agent’s public URL

The redirect URI must be a URL the browser can reach that lands back on your agent. Resolve it in priority order, explicit override, then the platform-injected public URL, then localhost for dev:

1const CALLBACK_PATH = "/oauth/callback";
2
3const redirectUrl =
4 process.env.OAUTH_REDIRECT_URL ??
5 (process.env.ASTRO_EXTERNAL_AGENT_URL
6 ? `${process.env.ASTRO_EXTERNAL_AGENT_URL.replace(/\/+$/, "")}${CALLBACK_PATH}`
7 : `http://localhost:${process.env.PORT ?? 8808}${CALLBACK_PATH}`);

ASTRO_EXTERNAL_AGENT_URL is injected only when your agent exposes a public endpoint. To get one, and to actually receive the redirect, declare a frontend interface and serve the callback path:

astropods.yml
1agent:
2 build:
3 context: .
4 dockerfile: Dockerfile
5 interfaces:
6 frontend: true # public host + ASTRO_EXTERNAL_AGENT_URL, routes :443 -> :80
7 messaging: true # keep the chat interface

Declaring frontend: true provisions a public HTTPS host, injects ASTRO_EXTERNAL_AGENT_URL, sets PORT=80, and routes the public host to your container on port 80. See Serve a frontend from your agent for the full topology.

Serve the callback on PORT (80 in production) — the same process that runs serve(agent) can host a tiny HTTP listener:

1Bun.serve({
2 port: Number(process.env.PORT ?? 8808),
3 fetch: async (req) => {
4 const url = new URL(req.url);
5 if (url.pathname !== CALLBACK_PATH) {
6 return new Response("ok", { status: 200 }); // 200 so ingress health checks pass
7 }
8 const code = url.searchParams.get("code");
9 // hand `code` to your provider/transport to finish the exchange, then 200
10 return new Response("Connected — you can close this tab.");
11 },
12});

Binding :80 requires root. In your Dockerfile, EXPOSE 80 and do not switch to a non-root USER, a non-root process can’t bind privileged ports and the bind will fail with EACCES.

Dockerfile
1# ... build ...
2EXPOSE 80
3CMD ["bun", "run", "agent/index.ts"]

With this in place, the OAuth redirect lands on https://<your-host>/oauth/callback, the ingress routes it to your container, and the flow completes without a manual step.

On a messaging-only agent (no exposed endpoint), ASTRO_EXTERNAL_AGENT_URL is unset and the redirect falls back to localhost. The browser still shows the ?code=... in its address bar, so you can complete the flow by pasting that code back to the agent — handy as a fallback, but the frontend setup above is what makes it automatic.

3. Persist tokens in a writable store

The production container filesystem is read-only, so a file-based token store is wiped on every restart. Persist OAuth tokens (and the dynamic client registration) in a writable store — a redis knowledge entry is the simplest:

astropods.yml
1knowledge:
2 cache:
3 provider: redis # injects REDIS_URL

Implement @mastra/mcp’s OAuthStorage interface (get / set / delete) against Redis when REDIS_URL is present, and fall back to a local file in dev. Tokens then survive restarts, and the MCP SDK refreshes expired access tokens from the stored refresh token automatically.

Because a deployed agent is headless, let it hand the user the authorization URL in chat. Expose a Mastra tool that starts the flow and returns the URL:

1import { createTool } from "@mastra/core/tools";
2import { z } from "zod";
3
4const connectService = createTool({
5 id: "connect_service",
6 description: "Begin connecting a tool provider via OAuth; returns a URL to open.",
7 inputSchema: z.object({ service: z.string() }),
8 execute: async ({ service }) => {
9 const authorizationUrl = await startAuthorization(service); // triggers provider redirect
10 return { authorizationUrl };
11 },
12});

The user opens the URL and approves; with the frontend callback from step 2 the connection completes on its own, and the agent re-lists that server’s tools so they’re immediately usable.

Gotchas with hosted MCP servers

  • Resource indicator mismatch (invalid_target). Per RFC 8707 the client sends a resource indicator. Some authorization servers reject the path-form resource their own Protected Resource Metadata advertises (e.g. https://host/mcp) and only accept the origin (https://host). If you hit invalid_target, pin the origin via the provider’s validateResourceURL hook.
  • Stale client registration (Invalid redirect_uri). A client registered under one redirect_uri (say, localhost during dev) is rejected if reused under a different one (your public URL after you expose a frontend). Clear stored OAuth data before a fresh connect so a new registration uses the current redirect_uri.
  • Instance restarts mid-flow. If consent state lives only in memory, a restart between issuing the URL and receiving the code breaks completion. Rebuild it from the persisted PKCE verifier + client registration so the paste/redirect still works.

The ASTRO_EXTERNAL_AGENT_URL variable

VariableValueWhen injected
ASTRO_EXTERNAL_AGENT_URLPublic HTTPS URL, e.g. https://<host>Only when the agent exposes a public endpoint (agent.interfaces.frontend: true) and a public host resolves.
ASTRO_AGENT_URL / ASTRO_AGENT_HOSTCluster-internal service URL / hostAlways (deployed). Reachable from other components in the deployment, not the public internet.
PORT80 on a frontend agentWhen frontend: true.

Use ASTRO_EXTERNAL_AGENT_URL whenever you need an absolute URL that points back at your agent from the outsidel, OAuth redirects, webhook callbacks, share links. It’s absent in local dev and on messaging-only agents, so always provide a localhost fallback.