One URL and nothing else: a remote MCP server with Bedrock AgentCore Gateway

By Benjamin Meudre
  • MCP
  • AgentCore Gateway
  • Amazon Bedrock
  • OAuth 2.1
  • Security
  • Terraform

MCP is how an AI assistant calls tools. A remote MCP server is one the assistant reaches over HTTPS, rather than a process running on the developer’s laptop. The server lives in your account, behind your own authentication, and everyone talks to the same one.

This is what it took to put a remote MCP server in front of a REST API that already existed, without writing a server: the architecture, the one genuinely hard problem, and the three places where the AWS documentation runs out.

TL;DR

  • No MCP server code. Amazon Bedrock AgentCore Gateway does the protocol translation from an OpenAPI spec. The backend keeps serving the same routes it already served to the web app.
  • One URL of client configuration. No token, no client secret, no service account, no AWS credentials on the developer’s machine.
  • An unusual topology. The same Lambda is both the identity provider the gateway trusts on the way in, and a confidential OAuth client of itself on the way out. There is no Cognito anywhere: the authorization server is hand-rolled inside the backend.
  • The hard part was keeping the human’s identity. Carrying it across the gateway hop takes two audiences and an RFC 8693 token exchange. The payoff is that the API authenticates the person who logged in, not the gateway.
  • Nothing can write. 18 read-only routes exposed, 23 excluded, decided in a single map that feeds both the spec generator and the runtime authorizer. The spec cannot advertise a tool the API would refuse.

In words, before the diagram: the IDE calls the gateway with no token and is told where to log in. It registers itself, opens a browser, and the user logs in the way they always do. From then on every tool call carries the token from that login, and the gateway swaps it for a second token that the API will accept.

Sequence diagram with four participants: the IDE, the browser, the backend Lambda acting as both API and OAuth server, and AgentCore Gateway. The IDE calls initialize with no token and gets a 401 carrying a WWW-Authenticate challenge. It then fetches the discovery document, registers itself dynamically, obtains a client_id with no secret, opens the authorization endpoint in the browser with PKCE, and the user completes the platform's normal passwordless login. The redirect to a loopback address returns a code, which the IDE exchanges for token A, audience platform-mcp-gateway. Token A only opens the gateway. On the first tool call, the gateway exchanges token A for token B, audience platform-api, and calls the API with it. The result flows back to the IDE.
Seventeen messages, of which the user sees one: the login page. Steps 3 to 11 happen once; steps 12 to 17 repeat on every tool call.

What already existed

A B2B platform for monitoring a fleet of connected field devices: a React SPA, an AWS Lambda backend with a typed schema-based API where every route is a POST, DynamoDB for storage, CloudFront in front. The API already existed and already worked, authenticated with cookie sessions from the web app.

The goal was to let AI agents use it. Two things I did not want: writing and operating an MCP server, and adding a second authentication path to a system whose first one was already audited.

One Lambda on both sides of the trust boundary

AgentCore Gateway takes an OpenAPI spec and an outbound credential provider, and publishes each operation as an MCP tool. That is the part that saves the work, and it is well documented.

Heterogeneous MCP clients reaching existing APIs through one gatewayThree kinds of MCP client — an agent in the IDE on a developer machine, a Strands agent hosted on Amazon Bedrock AgentCore Runtime, and a self-hosted in-app agent — all speak MCP to a single AgentCore Gateway. The gateway is the only component that speaks MCP. Below it, it calls several existing REST APIs over HTTPS from their OpenAPI descriptions. Adding a client or a target changes nothing on the other side.MCP clients, wherever they runIDE agentdeveloper machineStrands agentAgentCore Runtimefurther MCP clientsMCPAgentCore Gateway — one MCP entry pointREST APIOpenAPI, read-onlyThird-party APIoutbound credentialsInternal servicesame Lambdafurther targetsHTTPS
The gateway is the only component that speaks MCP, which is why no MCP server code was written. Above it, any MCP client, wherever it runs; below it, the APIs that already existed, called over plain HTTPS.

The part that is not obvious is what you point it at. The gateway needs two things: an inbound identity provider whose tokens it will accept, and outbound credentials for calling the target.

I used the backend for both. It is the OpenID provider the gateway trusts on the way in. It is also a confidential OAuth client of itself when the gateway comes back to exchange a token on the way out. Both hops land on the same Lambda, behind the same CloudFront distribution.

That is what makes a CUSTOM_JWT authorizer and a self-hosted discovery document sufficient, and it is why there is no Cognito in the design. The whole inbound story fits in one Terraform block:

resource "aws_bedrockagentcore_gateway" "mcp" {
  name          = local.mcp_gateway_name          # ([0-9a-zA-Z][-]?){1,48} — no underscores
  role_arn      = aws_iam_role.mcp_gateway_role.arn
  protocol_type = "MCP"

  # AWS_IAM would require the IDE to hold AWS credentials; NONE would make the gateway an open
  # proxy to the API.
  authorizer_type = "CUSTOM_JWT"

  authorizer_configuration {
    custom_jwt_authorizer {
      discovery_url = local.mcp_gateway_discovery_url
      # One half of the audience split. The backend accepts ONLY `platform-api`, so a token
      # presented here cannot be replayed there, and the exchange hop cannot be skipped.
      allowed_audience = ["platform-mcp-gateway"]
      # Also what the gateway advertises in the RFC 9728 `WWW-Authenticate` challenge: that is how
      # the IDE learns which scope to request, with nothing configured by hand.
      allowed_scopes = ["mcp:api"]
    }
  }

  # The only accepted value. The difference between "tool call failed" and "the token exchange
  # returned invalid_scope".
  exception_level = terraform.workspace == "prod" ? null : "DEBUG"
}

Why two tokens, not one

A token’s aud (audience) claim names the one service that token is valid for. Everything in this section follows from giving the two hops different audiences.

The IDE holds one token, the gateway obtains another, and neither works where the other belongs.

Token A Token B
aud platform-mcp-gateway platform-api
Held by the IDE the gateway, ~15 min
scope mcp:api api
Lifetime 1 h (+ 30 d refresh) 15 min, no refresh

Token A is what the browser login produces. The IDE stores it and sends it to the gateway. It opens the gateway and nothing else.

Token B is what the gateway obtains on each tool call, by handing token A back to the authorization server. It opens the API for fifteen minutes and is never refreshed.

The split exists so that neither hop can be skipped, and it cuts three ways:

  • The authorization server will not issue a platform-api token through the browser flow. So the IDE cannot obtain one and call the API directly.
  • The API will not accept a platform-mcp-gateway token. So what the IDE holds is useless against the API, even if it leaks.
  • Token B, if it leaks, opens the API for fifteen minutes and cannot be exchanged for anything else.

The claim that does the real work is sub, the subject: who this token is about. It is copied verbatim from token A into token B, so the gateway has no say in whose identity it forwards. That is what makes the API authenticate the human who logged in rather than the gateway that called it.

The exchange handler runs five steps, in an order chosen deliberately:

// 1. Authenticate the gateway BEFORE looking at the subject token, so an unauthenticated
//    caller learns nothing.
authenticateConfidentialClient({
  presented: extractClientCredentials({headers: req.headers, form: req.form}),
  expected: await getGatewayClientCredentials(),
});

// 2. Only the gateway audience is accepted here, so an API-audience token is rejected.
subjectClaims = verifyJwt({
  token: subjectToken,
  publicKey: await getOauthPublicKey(),
  issuer: getIssuer(),
  audience: MCP_GATEWAY_AUDIENCE,
});

// 3. The exchange may narrow scope, never widen it.
const exchange = validateTokenExchange({
  form: req.form,
  subjectClaims,
  apiResourceUris: [getIssuer()],
});

// 4. A live account is still required at exchange time.
const email = await store.getEmailBySubject({sub: exchange.subject});
if (email === undefined) {
  throw new OauthError({code: 'invalid_grant'});
}

// 5. `sub` is copied verbatim, which is what makes the API authenticate the human
//    rather than the gateway.
const accessToken = await signJwt({
  claims: buildAccessTokenClaims({
    issuer: getIssuer(),
    subject: exchange.subject,
    audience: exchange.audience, // platform-api
    scope: exchange.scope, // api
    ttlSeconds: exchange.ttlSeconds, // 15 min, no refresh
  }),
  signer,
});

Step 1 comes first for a reason. Authenticating the gateway before looking at the subject token means an unauthenticated caller learns nothing about whether a token it happens to hold is valid.

Two constraints protect that grant. It is the only confidential-client grant in the server — the only one whose caller proves itself with a secret — and its credentials are seeded out of band. No dynamically registered client can reach it.

Every other client registers itself, and registers as public: no secret, because an IDE on a laptop has nowhere to keep one. Public clients get PKCE instead, which proves that whoever finishes the login is whoever started it. Redirect URIs are restricted to loopback addresses, with the port deliberately not compared, per RFC 8252 §7.3.

That last rule is not tidiness. Registration is open by design, so allowing an arbitrary https:// callback would let an attacker register their own and lure a logged-in user through a forged /oauth/authorize. PKCE would not help there, because it is the attacker who holds the verifier.

Two smaller decisions in the same area, cheap enough that there was no reason not to:

  • JWTs are signed by KMS, with ECC_NIST_P256 — ES256 in JOSE terms. The Lambda is granted kms:Sign and kms:GetPublicKey, but deliberately not kms:Verify. Verification happens locally against a cached public key, so authenticating a request costs zero KMS calls and only issuance costs anything.
  • The sub claim is opaque, mapped to an email address through a table. An email never travels inside a token, and deleting an account kills every live token instantly by removing the mapping.

Read-only, decided in one place

No MCP tool can write. That is enforced by one map, which mechanically feeds both the OpenAPI generator and the runtime authorizer, so the spec can never advertise a tool the API would refuse. The exclusion reasons are data rather than comments, which means a test can require one:

export const EXCLUDED_FROM_MCP: Readonly<Record<string, string>> = {
  '/update-account': CHANGES_STATE,
  // ...
  // Writes the device status and its message history through `refreshDeviceStatus`, so it changes
  // state even though it reads like a query.
  '/resolve-device-anomaly-category': `${CHANGES_STATE} (persists a new device status)`,
  // Irreversible destruction of the account, and it takes no parameters, so a single confused or
  // injected instruction would be enough.
  '/delete-account': `${CHANGES_STATE}, irreversibly, and takes no parameters`,
  // Sends a login code by email, so an agent could email arbitrary addresses on demand.
  '/code': 'sends email to an arbitrary address',
};

/** Returns the scope required to call a route with a JWT, or undefined if it is not exposed. */
export function requiredScopeForJwtRoute(path: string): ApiScope | undefined {
  return MCP_ROUTES.has(path) ? API_ACCESS_SCOPE : undefined;
}

18 routes exposed, 23 excluded. Reading that map is the most useful hour of the project, because names lie. refreshDeviceStatus calls putAlertDeviceMessage: any route that refreshes a device status writes to the database, despite a getter-shaped name.

So I audited every exposed route by reading handler bodies for write primitives, rather than trusting the verb in the path. The residual risk is worth stating plainly: the guard tests reason on names, so a handler that acquires a write next month keeps the read-only tool it has today.

Three things the documentation does not tell you

GetResourceOauth2Token is authorized against three resources in sequence, and the documentation covers one. The documented resource is token-vault/<id>/oauth2credentialprovider/<name>. The action also requires workload-identity-directory/default/workload-identity/<gateway-id> and the vault root, token-vault/default. Each missing one produces the same opaque message in the MCP client — insufficient permissions for token exchange — naming no resource at all.

What made this genuinely expensive: aws iam simulate-principal-policy answers allowed when you ask it about the provider ARN, because that is not the resource being evaluated. CloudTrail is the only place that names the real resource, with minutes of indexing lag. I found the full set by fixing one, re-testing, and reading the next denial:

{
  # GetResourceOauth2Token is evaluated against THREE resources in sequence and denies on the
  # first one missing:
  #   1. workload-identity-directory/default/workload-identity/<gateway-id>
  #   2. token-vault/<vault-id>                      <- the vault root
  #   3. token-vault/<vault-id>/oauth2credentialprovider/<provider-name>
  # The AWS documentation lists only (3). Each missing one produces the same opaque
  # "insufficient permissions for token exchange", and CloudTrail is the only place that names
  # the resource actually being evaluated.
  Effect = "Allow"
  Action = ["bedrock-agentcore:GetResourceOauth2Token"]
  # NOT `token-vault/*`: an IAM `*` also matches `/`, which would grant this on every credential
  # provider in the vault.
  Resource = [
    "${local.arn_prefix}:token-vault/default",
    "${local.arn_prefix}:token-vault/*/oauth2credentialprovider/${local.provider_name}",
  ]
}

The vault root is written literally as token-vault/default rather than token-vault/* on purpose: an IAM * also matches /, so the wildcard form would grant the action on every credential provider in the vault.

CreateGateway fetches and parses your discovery URL, so on a fresh environment the first terraform apply must fail by design. Before the backend is deployed, that path returns the SPA’s index.html — a 200 with text/html — and creation fails with Failed to create gateway dependencies: Invalid discovery document. The order is therefore: apply, seed secrets, deploy, apply again, then provision the target.

The sharp edge is what the failure leaves behind. The gateway is absent from Terraform state but exists in AWS in FAILED status, so it has to be deleted by hand or they accumulate.

Two of the five AWS resources cannot be expressed in Terraform. AWS provider 6.58.0 exposes only client_id, client_secret and oauth_discovery under custom_oauth2_provider_config. There is no way to set onBehalfOfTokenExchangeConfig or clientAuthenticationMethod, and without those the gateway cannot exchange the user’s token at all — which is the entire mechanism. awscc ships awscc_bedrockagentcore_gateway, but neither _gateway_target nor _oauth2_credential_provider.

So the credential provider and the gateway target are created by an idempotent shell script calling bedrock-agentcore-control, and the cost is stated plainly in the code: no drift detection on those two.

What the client has to configure

This is the whole thing:

{
  "mcpServers": {
    "platform": {
      "url": "https://<gateway-id>.gateway.bedrock-agentcore.<region>.amazonaws.com/mcp"
    }
  }
}

No token, no secret, no service account. The client discovers the authorization server from that URL, registers itself, opens the browser, and every subsequent tool call runs as the human who logged in.

What I would do differently

The guard tests reason on route names. That was the pragmatic choice and it is the weakest part of the design. A route sits on the read-only list because of what its handler did on the day I read it, and nothing in the test suite would notice if that changed. What I actually want is a check on the handler’s call graph: if a route reachable from the MCP map can reach a write primitive, fail the build. That is a real piece of work rather than an afternoon, which is why it is not there yet.

The two resources created by shell script are a known liability. They are idempotent and they are in version control, but nothing detects drift on them, and I will not know if someone edits the credential provider in the console. I would rather have waited for provider support than build the script — except the feature does not work without those fields, so there was nothing to wait for.

The multi-step bootstrap is ugly. “Apply, expect a failure, deploy, apply again, clean up the FAILED gateway by hand” is not a procedure I want to hand to someone else. It is exactly the kind of thing that gets forgotten between two fresh environments six months apart. It wants to be one script that owns the whole sequence.

Read-only is a constraint I chose, not one the architecture imposes. Writes through MCP would need something this design does not have: a consent step the user actually sees, per tool, at call time. Until then, an agent that can only read is a limitation I am comfortable defending, and one I would be uncomfortable removing quietly.

All articles

Talk to us directly

Write to us directly. No salesperson, no qualification call. One of us two replies, within 24 hours.

contact@onescale.io

We reply within 24 hours, and it is one of us who replies.

Based in Lyon, working with clients in France, across Europe and internationally.