NewIntroducing Integrations
All guides
Credentials
Updated
10 min read
by Anlyon Team

How to let Claude Code or Cursor call a production API through MCP without the MCP server holding the key

Why a typical MCP server holds your raw API key, what env-var keys, remote OAuth servers and an execution layer each change, and how to connect Claude Code or Cursor to Anlyon so the agent names an action and Anlyon attaches the credential.

mcpclaude codecursorcredentialssecretsapprovals

Short answer: do not give the MCP server your production key at all. Keep the key in a vault the agent cannot read, define each production call once as a named action with the key referenced by name, and connect Claude Code or Cursor to an MCP server that can only ask for those actions. With the Anlyon MCP server, the agent names an action and passes input, and Anlyon attaches the credential and makes the request.

TL;DR

  • A typical local MCP server reads the raw key from its environment, so every agent session that can call its tools can spend that key.
  • Prompt injection and tool poisoning steer the agent, and the agent steers the server. Hiding the key from the model is not enough when the process the model drives holds it.
  • OAuth on a remote MCP server takes the key off your machine, but the remote server still makes every call its tools allow.
  • An execution layer moves the key and the call out of the agent's reach. The MCP server holds a credential that can ask for named actions, not one that can call the API.
  • Setup: claude mcp add --transport http anlyon https://api.anlyon.com/mcp for Claude Code, or "url": "https://api.anlyon.com/mcp" in .cursor/mcp.json for Cursor.

Why does an MCP server usually hold the raw API key?

Because that is how local servers are configured. The MCP authorization spec (revision 2026-07-28) makes authorization optional, and says implementations using the stdio transport should not follow its OAuth flow and should "instead retrieve credentials from the environment". In practice that means a block like this in a project's .mcp.json or in ~/.cursor/mcp.json:

{
  "mcpServers": {
    "billing": {
      "command": "npx",
      "args": ["-y", "some-billing-mcp-server"],
      "env": { "BILLING_API_KEY": "sk_live_..." }
    }
  }
}

The server process now holds a key that can do everything the billing API allows. The model never sees the string, and that does not help much. The model decides which tools to call and with what arguments, and the server makes those calls with the key.

Two things can steer the model:

  • Prompt injection. Text the agent reads in a file, an issue, a web page or a tool result tells it to call a tool it should not, or with an argument it should not.
  • Tool poisoning. On 2025-04-01, Invariant Labs published a demonstration in which hidden instructions in one MCP server's tool description led Cursor's agent to read ~/.cursor/mcp.json and an SSH key and send them to that server. Their write-up notes that mcp.json is typically used to store credentials for other MCP servers. A raw key in that file is one file read away from leaving.

Both clients ask before an MCP tool runs by default. Cursor's MCP docs say so, and Claude Code prompts for MCP tools in its default mode (permissions). That prompt is shown in the same session the injected text is steering, and an allow rule or an auto mode turns it off.

What are the options for keeping the key out of the MCP server?

There are three common patterns. They differ in where the production credential lives and in what a hijacked session can still do.

Env-var key in a local serverOAuth on a remote MCP serverExecution layer behind the MCP server
Where the production credential livesIn your config file and the server processOn the remote serverIn a vault the agent and the MCP server cannot read
What the MCP client holdsNothing; the server holds the keyAn access token issued for the MCP serverA token or scoped key that can ask for named actions
What a hijacked session can callAnything the key allows, through the server's toolsAnything the granted tools allowOnly defined actions, each with a fixed host and input schema
A decision outside the sessionNoDepends on the serverAn approval decided outside the agent's session, bound to the request
A limit shared across sessionsNoDepends on the serverOne limit per environment for governed actions

OAuth on a remote server is a real improvement for the key itself. The spec requires the server to accept only tokens issued for it, and it forbids token passthrough: "MCP servers MUST NOT accept any tokens that were not explicitly issued for the MCP server." The remote server then uses its own credential for the upstream API. Nothing in the protocol limits what its tools do with it, and a session that can call a broad tool can make a broad call.

An execution layer changes what a tool is. The tool is not "call the billing API". It is "ask for issue-account-credit with this input". The service that holds the key decides whether the request runs, waits for a person when a rule says so, and makes the call. We wrote a longer piece on what an execution layer is.

How does the Anlyon MCP server let the agent call an API it holds no key for?

@anlyonhq/mcp-server gives the agent tools for asking, not tools for calling:

Without Anlyon   Claude Code -> MCP server (holds BILLING_API_KEY) -> api.yourapp.com
With Anlyon      Claude Code -> Anlyon MCP (holds an Anlyon credential)
                   -> action "issue-account-credit"
                     -> Anlyon resolves {{secret:BILLING_API_KEY}} at dispatch -> api.yourapp.com

By default the server exposes the action and approval tools and nothing else:

ToolWhat it does
list_actionsLists the workspace's actions, with input schemas and which ones require approval
invoke_actionInvokes an action by name. Each enabled action also appears as its own tool, action_<name>
request_approvalAsks a human to decide something that is not already an action
check_approvalReads an approval's status once
wait_for_approvalWaits for a decision, up to 120 seconds a call and 60 by default, and returns the still pending approval if nobody has decided
list_pending_approvals, decide_approvalReviewer tools. On the hosted endpoint they appear only if you grant "Approve on your behalf"
list_workspaces, select_workspaceChoose the workspace and environment the connection acts in

There is no tool that reads a secret. The vault is write-only: no API returns a secret's value. An action references the credential as {{secret:NAME}}, and Anlyon resolves it at dispatch. This covers credentials in the Anlyon vault, not a key your own process already holds.

Step 1: define the action, outside the editor

Run this once with an operator key, from a deploy script or your terminal, not from the agent. It stores the key, binds it to one host, and defines the one call the agent may ask for.

// setup.ts
import { Client } from '@anlyonhq/sdk';

const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });

await ops.secrets.put('BILLING_API_KEY', {
  value: process.env.BILLING_API_KEY!,
  allowedHosts: ['api.yourapp.com'],
  allowedPlacements: ['header'],
});

await ops.actions.create({
  name: 'issue-account-credit',
  description: 'Add a credit to a customer account. Amount is in cents.',
  method: 'POST',
  urlTemplate: 'https://api.yourapp.com/v1/accounts/{{input.accountId}}/credits',
  headers: { Authorization: 'Bearer {{secret:BILLING_API_KEY}}' },
  inputSchema: {
    type: 'object',
    properties: {
      accountId: { type: 'string', pattern: '^acct_' },
      amountCents: { type: 'integer' },
      reason: { type: 'string' },
    },
    required: ['accountId', 'amountCents', 'reason'],
    additionalProperties: false,
  },
  requiresApproval: true,
  impact: { dimension: 'money', unit: 'usd', amount: 'input.amountCents', bound: 'exact' },
});

What this buys you:

  • The host is fixed. An action that carries a secret has a fixed host, so an input cannot choose where the credential is sent. The accountId is encoded into one path segment and cannot change which endpoint is called.
  • Every call waits for a person. requiresApproval: true parks each invocation until someone decides. Removing the gate needs actions:govern, which no key gets by default.
  • It counts against a shared limit. Declaring impact makes this a governed action. If you set a limit on it, every agent, session and key in the environment draws from the same limit, reserved before dispatch. A new Claude Code session does not get a new allowance. The limit holds within one environment, for actions routed through Anlyon.

See the docs on actions and secrets for the full template rules.

Step 2: connect Claude Code

Claude Code connects to Anlyon's hosted MCP endpoint over streamable HTTP and signs you in with OAuth. There is no API key to paste.

claude mcp add --transport http anlyon https://api.anlyon.com/mcp

Then run /mcp inside Claude Code, select anlyon, and sign in. On the consent screen you choose the workspace, the environment and the permissions. "Approve on your behalf" is off unless you turn it on, and Claude Code sees only the tools your grants cover. claude mcp add registers the server for you in the current project by default; add --scope user for every project (Claude Code MCP docs).

For CI or a machine without a browser, run the local server with a scoped Anlyon key, and let .mcp.json read it from the environment rather than storing it. Claude Code expands ${VAR} in .mcp.json:

{
  "mcpServers": {
    "anlyon": {
      "command": "npx",
      "args": ["-y", "@anlyonhq/mcp-server"],
      "env": { "ANLYON_API_KEY": "${ANLYON_API_KEY}" }
    }
  }
}

Give that key actions:read, actions:invoke, approvals:read and approvals:write, and not approvals:decide. Its scopes are exactly what Claude Code can do. Full steps: Claude Code docs.

Step 3: connect Cursor

Add the hosted endpoint through Cursor's "Add MCP server" dialog, or in .cursor/mcp.json for one project (~/.cursor/mcp.json for every project):

{
  "mcpServers": {
    "anlyon": {
      "url": "https://api.anlyon.com/mcp"
    }
  }
}

Cursor then asks you to sign in. The same consent screen applies: workspace, environment, and permissions, with "Approve on your behalf" off by default.

Without a browser, use the local server in ~/.cursor/mcp.json, not a committed project file. Cursor resolves ${env:NAME} in the env field (Cursor MCP docs), so the Anlyon key can stay in your shell environment:

{
  "mcpServers": {
    "anlyon": {
      "command": "npx",
      "args": ["-y", "@anlyonhq/mcp-server"],
      "env": { "ANLYON_API_KEY": "${env:ANLYON_API_KEY}" }
    }
  }
}

Full steps: Cursor docs.

What happens when the agent calls the action?

Ask Claude Code or Cursor to credit acct_8812 $40 for a failed renewal. The agent:

  1. Calls action_issue-account-credit with { "accountId": "acct_8812", "amountCents": 4000, "reason": "Failed renewal" }. The input is validated against the schema before anything is sent.
  2. Gets back a parked invocation with an apr_ approval id and an instruction to wait.
  3. Calls wait_for_approval. If nobody decides within the wait, it gets the still pending approval and calls again.

A teammate approves in the Anlyon dashboard. The human approves the exact change, and that exact change is what runs: Anlyon resolves BILLING_API_KEY, sends the reviewed request to api.yourapp.com, and records the outcome. The agent's session never held the key at any point.

If the response from your API is lost, Anlyon does not retry an action invocation. Where a request may have reached the destination and the outcome cannot be confirmed, the invocation is recorded as unknown and you reconcile before retrying. Tell the agent not to retry an unknown; the project context block in the docs does that for Claude Code and Cursor.

What does this not protect?

  • Keys the agent can already read. If BILLING_API_KEY is still in a .env file in the repository Claude Code works in, the agent can read it with a shell command. Move it to the vault and out of the working tree.
  • Calls that do not go through Anlyon. A tool your own code calls directly is not routed through Anlyon and is not governed by it. If another MCP server in the same config holds a raw key, that path is still open.
  • The Anlyon credential itself. The OAuth token or Anlyon key can ask for any action its scopes cover. Keep actions narrow, gate the ones that write, and leave approvals:decide off the agent's key. The credential that requested an approval can never be the one that decides it.

For the wider picture, read AI agent secrets management and AI agent tool permissions. For the same pattern from code instead of an editor, see how to let an AI agent call the Stripe API without giving it your key.

Free during Early Beta Access, no credit card. One command if you use Claude Code or Cursor. Start building →

Frequently asked questions

Where does an MCP server normally get its API key?

For a local stdio server, from its environment. The MCP authorization spec says stdio implementations should not use its OAuth flow and should instead retrieve credentials from the environment, so the key usually sits in the env block of .mcp.json or ~/.cursor/mcp.json and in the server process. Any session that can call that server's tools can make calls with that key.

Does putting the MCP server behind OAuth solve this?

It moves the key off your laptop. The client holds an access token issued for the MCP server, and the spec forbids passing that token through to the upstream API. The remote server still holds its own credential for the upstream API and runs whatever its tools allow, so a hijacked session can still call every tool you granted.

Does the Anlyon MCP server hold my production API key?

No. It holds an Anlyon credential: an OAuth token from Login with Anlyon on the hosted endpoint, or a scoped Anlyon API key on the local server. Your production key sits in the Anlyon vault, an action references it as {{secret:NAME}}, and Anlyon resolves it at dispatch. This covers credentials in the vault, not a key your own process or repository already holds.

Can the agent in Claude Code approve its own request?

No. The credential that requested an approval can never be the one that decides it. On the hosted endpoint the decide tools appear only if you turn on Approve on your behalf, and even then a connection cannot decide an approval it requested itself. Decide those from the dashboard or another connection.

Continue with a related task

Free tier, no credit card. One command if you use Claude or Cursor.

$ claude mcp add anlyon -- npx -y @anlyonhq/mcp-server