---
title: "API Key Authentication for MCP Servers"
description: "How to connect an agent to an API-key-protected MCP server, with working code for the OpenAI Agents SDK, Pydantic AI, LangChain, Vercel AI SDK, Mastra, and more."
canonicalUrl: "https://zuplo.com/learn/mcp/authentication/api-key"
pageType: "mcp-auth-guide"
method: "api-key"
lastVerified: "2026-07-24"
specVersion: "2026-07-28"
---

# API Key Authentication for MCP Servers

> A static key in a request header is the most common way to authenticate service-to-service calls to a remote MCP server.

**API key · Any platform**

## The exchange

The agent sends its configured API key on the very first request. The MCP server validates the key, resolves it to a caller, and answers. Every later request repeats the same header — there is no 401, no discovery request and no token exchange anywhere in the flow.

**Participants:** Your agent (Cloud Run, Lambda, CI), MCP server (validates the key)

1. **Your agent → MCP server** — `POST /mcp` — `Authorization: Bearer sk_live_…` _(carries the credential)_
2. **MCP server** (internal step) — Look the key up and resolve it to a caller
3. **MCP server → Your agent** — `200 OK` — `initialize result — tools listed` _(response)_
4. **Your agent → MCP server** — `POST /mcp` — `tools/call, same Authorization header` _(carries the credential)_
5. **MCP server → Your agent** — `200 OK` — `tool result` _(response)_

Notes:

- **Step 1** — The key is configured when the client is constructed, so the first message on the wire is already authenticated: no 401, no .well-known lookup, no browser step.
- **Steps 4–5** — There is no session credential to establish. Every subsequent request carries the same static key.

## How it works

The agent sends `Authorization: Bearer <key>` on every request to the MCP server. The server checks the key and resolves it to a caller before running a tool. Agent SDKs take the header when the client is constructed, so the first request on the wire is an authenticated `initialize` — there is no unauthenticated probe, no 401, no `.well-known` lookup and no browser step.

_MCP Specification 2026-07-28._

### At a glance

- **Best for** — Service-to-service calls where you control both the agent and the MCP server
- **MCP spec** — Outside the spec's OAuth flow, which the spec makes optional
- **Works with** — Supported by every agent SDK; hosted connectors are more limited
- **Effort** — An afternoon

## Connect your agent

Each sample connects to the server, sends the credential, and exposes the tools to the model.

### TypeScript

#### Claude Agent SDK

```typescript title="agent.ts"
import { query } from "@anthropic-ai/claude-agent-sdk";

// `type: "http"` is streamable HTTP; `"sse"` is the older transport.
for await (const message of query({
  prompt: "Search for open orders.",
  options: {
    mcpServers: {
      example: {
        type: "http",
        url: "https://mcp.example.com/mcp",
        headers: { Authorization: `Bearer ${process.env.MCP_API_KEY}` },
      },
    },
    // Auto-approves these tools. Anything unlisted stops for a
    // permission prompt, which a headless run never gets past.
    allowedTools: ["mcp__example__search"],
  },
})) {
  console.log(message);
}
```

#### Vercel AI SDK

```typescript title="agent.ts"
import { createMCPClient } from "@ai-sdk/mcp";
import { generateText, isStepCount } from "ai";

// The MCP client lives in @ai-sdk/mcp, not `ai`. `type: "http"` is
// streamable HTTP — do not use `type: "sse"`.
const mcp = await createMCPClient({
  transport: {
    type: "http",
    url: "https://mcp.example.com/mcp",
    headers: { Authorization: `Bearer ${process.env.MCP_API_KEY}` },
  },
});

try {
  const { text } = await generateText({
    model: "openai/gpt-5.4",
    tools: await mcp.tools(),
    stopWhen: isStepCount(10),
    prompt: "List your tools.",
  });
  console.log(text);
} finally {
  await mcp.close();
}
```

#### MCP TypeScript SDK

```typescript title="agent.ts"
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";

// First argument must be a URL object, not a string.
const transport = new StreamableHTTPClientTransport(
  new URL("https://mcp.example.com/mcp"),
  {
    requestInit: {
      headers: { Authorization: `Bearer ${process.env.MCP_API_KEY}` },
    },
  },
);

const client = new Client({ name: "my-agent", version: "1.0.0" });
await client.connect(transport);
const { tools } = await client.listTools();
```

#### OpenAI Agents SDK

```typescript title="agent.ts"
import { Agent, run, MCPServerStreamableHttp } from "@openai/agents";

const mcpServer = new MCPServerStreamableHttp({
  url: "https://mcp.example.com/mcp",
  name: "Example MCP Server",
  requestInit: {
    headers: { Authorization: `Bearer ${process.env.MCP_API_KEY}` },
  },
});

const agent = new Agent({ name: "Assistant", mcpServers: [mcpServer] });
try {
  await mcpServer.connect(); // required before run()
  console.log((await run(agent, "List your tools.")).finalOutput);
} finally {
  await mcpServer.close();
}
```

#### Mastra

```typescript title="agent.ts"
import { MCPClient } from "@mastra/mcp";
import { Agent } from "@mastra/core/agent";

const mcp = new MCPClient({
  servers: {
    example: {
      url: new URL("https://mcp.example.com/mcp"), // URL object, not string
      requestInit: {
        headers: { Authorization: `Bearer ${process.env.MCP_API_KEY}` },
      },
    },
  },
});

const agent = new Agent({
  id: "mcp-agent",
  name: "MCP Agent",
  instructions: "Use the MCP tools.",
  model: "openai/gpt-4o",
  tools: await mcp.listTools(),
});
```

### Python

#### Claude Agent SDK

```python title="agent.py"
import asyncio, os
from claude_agent_sdk import query, ClaudeAgentOptions

options = ClaudeAgentOptions(
    mcp_servers={
        "example": {
            "type": "http",
            "url": "https://mcp.example.com/mcp",
            "headers": {"Authorization": f"Bearer {os.environ['MCP_API_KEY']}"},
        }
    },
    # Tools arrive namespaced `mcp__<server>__<tool>`. Anything not
    # listed here stops for a permission prompt, which in a headless
    # run there is nobody to answer.
    allowed_tools=["mcp__example__search"],
)

async def main():
    async for message in query(prompt="Search for open orders.", options=options):
        print(message)

asyncio.run(main())
```

#### OpenAI Agents SDK

```python title="agent.py"
import asyncio, os
from agents import Agent, Runner
from agents.mcp import MCPServerStreamableHttp

async def main():
    # The connection must be open before Runner.run — passing an
    # unconnected server to Agent() silently yields zero tools.
    async with MCPServerStreamableHttp(
        name="Example MCP",
        params={
            "url": "https://mcp.example.com/mcp",
            "headers": {"Authorization": f"Bearer {os.environ['MCP_API_KEY']}"},
        },
    ) as server:
        agent = Agent(name="Assistant", mcp_servers=[server])
        result = await Runner.run(agent, "List your tools.")
        print(result.final_output)

asyncio.run(main())
```

#### Pydantic AI

```python title="agent.py"
import os
from pydantic_ai import Agent
from pydantic_ai.mcp import MCPToolset

# v2 renamed MCPServerStreamableHTTP to MCPToolset, and
# Agent(mcp_servers=...) to toolsets=[...].
toolset = MCPToolset(
    "https://mcp.example.com/mcp",
    headers={"Authorization": f"Bearer {os.environ['MCP_API_KEY']}"},
)
agent = Agent("openai:gpt-5.2", toolsets=[toolset])

async def main():
    result = await agent.run("List your tools.")
    print(result.output)
```

#### LangChain

```python title="agent.py"
import os
from langchain_mcp_adapters.client import MultiServerMCPClient
from langchain.agents import create_agent

client = MultiServerMCPClient({
    "example": {
        "transport": "streamable_http",
        "url": "https://mcp.example.com/mcp",
        "headers": {"Authorization": f"Bearer {os.environ['MCP_API_KEY']}"},
    }
})

async def main():
    agent = create_agent("openai:gpt-4.1", await client.get_tools())
    print(await agent.ainvoke({"messages": "List your tools."}))
```

#### FastMCP

```python title="agent.py"
import os
from fastmcp import Client

# A bare string for `auth` is sent as `Authorization: Bearer <token>`.
client = Client(
    "https://mcp.example.com/mcp",
    auth=os.environ["MCP_API_KEY"],
)

async def main():
    # `async with` is required — calls outside it fail.
    async with client:
        print(await client.list_tools())
```

### Go

#### MCP Go SDK

```go title="main.go"
import (
    "context"
    "os"

    "github.com/modelcontextprotocol/go-sdk/mcp"
    "golang.org/x/oauth2"
)

// StreamableClientTransport has no headers field — you supply an
// http.Client. oauth2's static token source is the shortest way to
// get exactly `Authorization: Bearer <token>`.
func connect(ctx context.Context) (*mcp.ClientSession, error) {
    client := mcp.NewClient(
        &mcp.Implementation{Name: "my-agent", Version: "1.0.0"}, nil)

    return client.Connect(ctx, &mcp.StreamableClientTransport{
        Endpoint: "https://mcp.example.com/mcp",
        HTTPClient: oauth2.NewClient(ctx, oauth2.StaticTokenSource(
            &oauth2.Token{AccessToken: os.Getenv("MCP_API_KEY")})),
    }, nil)
}
```

#### mcp-go

```go title="main.go"
import (
    "context"
    "os"

    "github.com/mark3labs/mcp-go/client"
    "github.com/mark3labs/mcp-go/client/transport"
    "github.com/mark3labs/mcp-go/mcp"
)

c, err := client.NewStreamableHttpClient("https://mcp.example.com/mcp",
    transport.WithHTTPHeaders(map[string]string{
        "Authorization": "Bearer " + os.Getenv("MCP_API_KEY"),
    }))
if err != nil { return err }
defer c.Close()

// Unlike the official SDK, Start and Initialize are both explicit.
if err := c.Start(ctx); err != nil { return err }
req := mcp.InitializeRequest{}
req.Params.ProtocolVersion = mcp.LATEST_PROTOCOL_VERSION
req.Params.ClientInfo = mcp.Implementation{Name: "my-agent", Version: "1.0.0"}
if _, err := c.Initialize(ctx, req); err != nil { return err }

tools, err := c.ListTools(ctx, mcp.ListToolsRequest{})
```

### C#

#### MCP C# SDK

```csharp title="Program.cs"
using ModelContextProtocol.Client;

// HttpClientTransport — SseClientTransport was renamed and no longer
// exists. Set TransportMode explicitly so a misconfigured server
// errors instead of silently downgrading to SSE.
var transport = new HttpClientTransport(new HttpClientTransportOptions
{
    Endpoint = new Uri("https://mcp.example.com/mcp"),
    TransportMode = HttpTransportMode.StreamableHttp,
    AdditionalHeaders = new Dictionary<string, string>
    {
        ["Authorization"] =
            $"Bearer {Environment.GetEnvironmentVariable("MCP_API_KEY")}",
    },
});

await using var client = await McpClient.CreateAsync(transport);
foreach (var tool in await client.ListToolsAsync())
    Console.WriteLine(tool.Name);
```

### Java

#### MCP Java SDK

```java title="Agent.java"
import io.modelcontextprotocol.client.McpClient;
import io.modelcontextprotocol.client.McpSyncClient;
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;

// builder() takes the ORIGIN; endpoint() is separate. Passing a full
// URL silently drops path segments other than /mcp.
var transport = HttpClientStreamableHttpTransport
        .builder("https://mcp.example.com")
        .endpoint("/mcp")
        .httpRequestCustomizer((request, method, uri, body, context) ->
                request.header("Authorization",
                        "Bearer " + System.getenv("MCP_API_KEY")))
        .build();

try (McpSyncClient client = McpClient.sync(transport).build()) {
    client.initialize();
    client.listTools().tools().forEach(t -> System.out.println(t.name()));
}
```

#### Spring AI

```java title="Agent.java"
import io.modelcontextprotocol.client.transport.HttpClientStreamableHttpTransport;
import org.springframework.ai.mcp.customizer.McpClientCustomizer;

// The starter's YAML has url and endpoint but no headers field, so a
// customizer bean is the only way to attach the credential.
@Bean
McpClientCustomizer<HttpClientStreamableHttpTransport.Builder> bearerAuth() {
    String token = System.getenv("MCP_API_KEY");
    return (name, builder) -> builder.httpRequestCustomizer(
            (request, method, uri, body, context) ->
                    request.header("Authorization", "Bearer " + token));
}
```

### Rust

#### MCP Rust SDK

```rust title="main.rs"
use rmcp::{
    ServiceExt,
    model::ClientInfo,
    transport::{
        StreamableHttpClientTransport,
        streamable_http_client::StreamableHttpClientTransportConfig,
    },
};

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let transport = StreamableHttpClientTransport::from_config(
        StreamableHttpClientTransportConfig::with_uri("https://mcp.example.com/mcp")
            // Raw token — rmcp adds the "Bearer " prefix itself.
            .auth_header(std::env::var("MCP_API_KEY")?),
    );

    let client = ClientInfo::default().serve(transport).await?;
    for tool in client.list_all_tools().await? {
        println!("{}", tool.name);
    }
    Ok(())
}
```

## When to use something else

**Use this when:**

- Both ends belong to you: an agent calling your own MCP server, a scheduled job, a CI step.
- One credential can represent the whole integration, because no end user's identity needs to reach the API behind the server.
- You need something that works across every agent SDK without a browser step.

**Use something else when:**

- The agent and the MCP server run in the same cloud, where workload identity removes the secret entirely.
- The server will be listed in the ChatGPT or Claude connector directories, both of which require the spec OAuth flow.
- Calls are made on behalf of a named person, or the audit trail has to identify a user rather than a caller.

> The specification makes authorization OPTIONAL and says HTTP transports SHOULD, rather than MUST, conform to its OAuth flow. A static key therefore sits outside the spec rather than in conflict with it. The spec provides no way to advertise that a server expects a key, which is why each caller has to be configured directly.

## What lands on you next

A static key answers exactly one question: is this caller allowed in. Every question asked of you after it ships is a different question.

- **Which tools can this key call?** — `mcp-capability-filter-inbound` — An exact allow-list of tools, prompts and resources per route. A call to a hidden tool is refused with `MethodNotFound` before it reaches your server.
- **Who ran `refund_order` at 3am?** — `capability_invocation` — Every tool call emits an event carrying the caller, the capability, the upstream, the outcome and the latency. Tokens and request bodies are never logged.
- **That key is in five agents' env vars. How do we rotate it?** — `set-upstream-api-key-inbound` — Agents authenticate to the gateway, which strips that credential and injects the upstream key — so the secret your agents hold and the one your server accepts stop being the same secret.
- **Sales wants this in Claude with our Okta login. Do we rewrite the server?** — `mcp-okta-oauth-inbound` — No — the inbound method is a policy, and there is a first-class one for eleven named identity providers plus any OIDC provider. Exactly one per project, and the provider's token never reaches the MCP client.

All of these attach to one MCP route's `policies.inbound` — the same policy engine on the way in and on the way out. See [Zuplo MCP Gateway](/mcp-gateway).

## FAQ

**Does MCP require OAuth, or can I use an API key?**

An API key is permitted. The specification makes authorization optional and says HTTP transports SHOULD, rather than MUST, conform to its OAuth flow. The real limits are hosted-connector support and directory listing, not spec compliance.

**Do agent SDKs run the OAuth discovery flow automatically?**

Not when a header is configured. Supplying headers means no auth provider is wired up, so the credential goes out on the first request and no 401 is ever exchanged. Discovery applies to clients that connect without a credential.

**Why do so many MCP servers accept a fixed token instead of implementing OAuth?**

The server usually sits in front of an API that already authenticates with a token. Running an authorization server to reach the same result adds work without adding security when both ends belong to the same team.

**Can one endpoint accept both static keys and OAuth?**

Yes, and it has to if both audiences matter. Validate the static credential first and dispatch when it succeeds; fall through to a 401 with WWW-Authenticate only when nothing usable was presented. Reversing that order pushes callers holding a valid key into a discovery flow.

**Why would a client ignore the header it was given and attempt OAuth?**

Some clients read /.well-known/oauth-protected-resource on connect and treat its presence as proof that OAuth is required. A server that accepts static keys should either omit that document or ensure a valid key dispatches before any 401 is written.

**Is a header name other than Authorization acceptable?**

In your own agents, yes: every SDK shown here sets arbitrary headers. The Anthropic Messages API connector exposes only authorization_token, so a server that insists on X-API-Key cannot be reached through it. Authorization: Bearer is the portable choice.

**Should an API key be used when the agent and MCP server share a cloud?**

Usually not. On Cloud Run, Lambda or a shared Kubernetes cluster the platform can issue a short-lived identity token instead, which removes the stored secret and makes the caller's identity verifiable rather than looked up.

**How does a server move from API keys to OAuth?**

Accept both during the transition by validating the bearer token as either a key or a JWT, then withdraw key acceptance one caller at a time. Handling this at a gateway makes it a single change rather than one per server.

## Keep reading

- **MCP Authentication** — [every authentication method](/learn/mcp/authentication)
