Connect to OAuth-protected MCP servers

Connect a deployed agent to hosted MCP servers and complete the OAuth flow at deploy time using your agent's public URL
View as Markdown

Hosted MCP servers expose their tools over OAuth 2.1. An agent deployed on Astro AI can connect to one and complete the OAuth flow at runtime: the platform injects the agent’s public URL as ASTRO_EXTERNAL_AGENT_URL, which is exactly what the OAuth redirect URI must point at.

The redirect URI is the part to get right on a deployed agent. After a user approves access, the OAuth provider sends their browser to that URL with an authorization code, so it must resolve back to your agent. Locally that’s http://localhost; in production it must be your agent’s public hostname. Build the redirect from ASTRO_EXTERNAL_AGENT_URL at runtime and the flow completes without a manual step.

This guide uses a Mastra agent as the worked example, with @mastra/mcp for the OAuth client: MCPClient pulls the tools, and MCPOAuthClientProvider handles dynamic client registration (RFC 7591) and PKCE. The same approach applies to any MCP client; only the provider API differs.

Before you start

  • A Mastra agent you can run and deploy.
  • A deployable Astropods blueprint (astropods.yml) for the agent.
  • The ast CLI installed and authenticated.
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

Build 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 store 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.

5

Deploy and verify

Deploy the blueprint and open the URL it returns:

$ast blueprint deploy <name>

Start the connect flow from chat, open the authorization URL, and approve. The callback lands on https://<your-host>/oauth/callback, and you should see Connected.

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 outside: OAuth redirects, webhook callbacks, share links. It’s absent in local dev and on messaging-only agents, so always provide a localhost fallback.

Next steps