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.
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.
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-apitoken through the browser flow. So the IDE cannot obtain one and call the API directly. - The API will not accept a
platform-mcp-gatewaytoken. 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 grantedkms:Signandkms:GetPublicKey, but deliberately notkms:Verify. Verification happens locally against a cached public key, so authenticating a request costs zero KMS calls and only issuance costs anything. - The
subclaim 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.