The Model Context Protocol M-shaped mark and the Model Context Protocol wordmark in black on a white card

Deploying an MCP server on Bedrock AgentCore — Cognito OAuth, and a benchmark that lied

Deploying a Python MCP server on Bedrock AgentCore with Cognito OAuth: the IAM action that is not InvokeModel, and a 225x benchmark that was really 2.3x.

On this page

Introduction

I was asked to build a Python MCP server on Amazon Bedrock AgentCore Runtime, authenticated with Cognito, and I had never used AgentCore before. There was a client demo at the end of it, which is a good way to find out what a service actually requires rather than what its quickstart implies.

The build itself went fine. What did not go fine was the benchmark I wanted to show at that demo. The first measurement said the design we were arguing for used 225 times fewer input tokens than the alternative, and that number was wrong: the agent had implemented the comparison tool without the upstream API’s filter parameters, so it was measuring a handicap rather than a design. Fixed properly, the advantage was 2.3x.

This article covers how the tools were designed, how the server was verified before and after deploying, the traps that cost real time, and why the smaller number turned out to be the more useful finding.

What AgentCore Runtime requires

Runtime hosts a container and expects an MCP server at a fixed address. None of it is configurable:

  • Bind 0.0.0.0, port 8000, path /mcp
  • Streamable HTTP only, no SSE
  • stateless_http=True unless you need elicitation or sampling

The part worth knowing before you plan anything: there is no IAM SigV4 path for a deployed MCP server. An unauthenticated call returns a 401 carrying the OAuth protected-resource metadata, which is genuinely useful for confirming the endpoint is wired.

HTTP/2 401
www-authenticate: Bearer resource_metadata="https://bedrock-agentcore.<region>.amazonaws.com/runtimes/<encoded-arn>/invocations/.well-known/oauth-protected-resource?qualifier=DEFAULT"

So Cognito was not a preference here. Without an identity provider there is no way to call the thing at all.

There is a second AgentCore product, Gateway, which turns an existing API into MCP tools without you writing a server. It was the wrong choice here and the reason generalises: Gateway is passthrough, so the upstream API’s shape becomes the tool surface. A paginated search endpoint arrives at the model as a paginated search tool, which rebuilds the exact round-tripping this project existed to remove. Runtime hosts your own container, so the joining can happen in Python before anything reaches the model.

Request path from client to downstream servicesTwo clients at the top send an OAuth access token into a single AgentCore Runtime card in the middle. Inside that card, four Python steps run in order: normalise, canonicalise, query and paginate, then group. Below it, three downstream services are reached over ordinary HTTP rather than over MCP. A note records that a call without a token is rejected with a 401.Internal web appClaude CodeOAuth 2.0 access tokenNo token → 401 + WWW-AuthenticateAgentCore Runtime (MCP)Python logic lives herenormalisecanonicalisequery + paginategroupPlain HTTP, not MCPBedrockVocabulary lookupUpstream data API
MCP is the inbound protocol only. Every downstream hop is ordinary HTTP.

Designing one tool per use case

The claim under test was that MCP tools should be defined per use case, not per service. A per-service tool exposes an endpoint and leaves the model to search, page, fetch each record and join the results. A use-case tool does all of that inside the runtime and returns one answer.

Per-service tools versus one use-case toolTwo cards side by side. On the left, the model calls several raw endpoints and joins the results itself, costing multiple round trips. On the right, the model makes a single call and the runtime does the normalising, filtering, paging and grouping in Python before returning one answer.Per-service toolsLLMSearches, pages, fetches eachrecord, and joins them itselfN round tripsRaw endpointssearchget recordterm lookup2 calls · 8,006 tokensUse-case toolLLMReceives one answer,already assembled1 callJoined in Python, inside the runtimenormalisefilter + paginategroup by sponsor1 call · 3,542 tokens
The same question, answered two ways. The difference is where the joining happens.

Concretely, the use-case tool took four arguments and did six things with them:

Argument Default Meaning
condition required Disease name, accepted in Japanese or English
country none Restrict to trials with a site in that country
phase none PHASE1 through PHASE4
status RECRUITING Upstream status filter

Inside one call: normalise the condition to English, resolve it to a controlled-vocabulary identifier, query the upstream API with its server-side filters, follow pagination to exhaustion, group by sponsor, and compact the result. The response came back at roughly 4 KB describing 19 trials across 6 sponsors.

The one decision worth calling out is the response shape, because it is where the design can quietly undo itself. Returning a flat list of trials is honest but pushes the grouping back into the model, which is the work the tool exists to absorb. A sponsor-keyed summary with a per-sponsor cap and an explicit truncated flag keeps the joining server-side without silently discarding rows.

Where natural language actually gets interpreted

This is the part that is easy to get backwards, and I did. The MCP server does not parse Japanese sentences. It parses Japanese disease names. Turning a question into arguments is the model’s job, and it happens before the server is involved:

「肥満症で日本で実施中の第III相試験を、スポンサー別に」
│ the model does this
find_active_trials_by_sponsor(condition="肥満症", country="Japan", phase="PHASE3")
▼ everything below here is the server

That boundary matters when reading the benchmark below. Both paths were given the same question and the same model; only the tool surface differed.

Normalisation itself has a caveat worth stating. The server calls a small Bedrock model to convert the disease name, but a handful of common terms are also held in a static map so a demo does not die if Bedrock is unreachable. Anything outside that map goes to the model, and if that fails the tool returns the input unchanged with a warning rather than inventing a term.

The FastMCP class no longer exists

Every AWS sample opens the same way:

from mcp.server.fastmcp import FastMCP

On a fresh install that fails:

ModuleNotFoundError: No module named 'mcp.server.fastmcp'

Version 2.0.0 of the mcp package removed FastMCP and replaced it with MCPServer. The replacement is perfectly capable, and run_streamable_http_async() takes host, port, streamable_http_path and stateless_http, which is everything the runtime contract needs. The agent’s recommendation was to pin mcp>=1.9,<2 anyway, on the grounds that matching the samples matters more than being current when there is a deadline and no prior experience with the platform. I took it. It is a five-line migration whenever the samples catch up.

Testing tool discovery before touching AWS

The whole build ran local first, and that ordering earned its keep. Running the server locally separates “my logic is wrong” from “AgentCore is misconfigured”, which are otherwise indistinguishable at the point where a deployed call returns nothing.

The order each step was verified inFive steps stacked vertically, each with the evidence it had to produce before the next began: a local server listing five tools, Cognito minting an access token, a deploy returning a runtime ARN, an external client holding only a bearer token getting real data back, and Claude Code reporting a connection. The fourth step also records that a call without a token is rejected with a 401.Had to produce1Local serverlocalhost:8000/mcplist_tools() → 5 tools2Cognitopool, app client, test useraccess token minted3Deployzip to S3, no Dockerruntime ARN returned4External clientbearer token only, no IAM200 + real data401 without a token5Claude CodeHTTP transport✔ Connected
Each step had to produce its evidence before the next one started.

The runtime contract is just an HTTP server, so the local form is the same thing on localhost:

Terminal window
uv run python mcp_hub/server.py # serves http://localhost:8000/mcp

The client used to exercise it is deliberately the same script for local and deployed, switching only on whether an agent ARN is present:

async with streamablehttp_client(url, headers, timeout=120) as (read, write, _):
async with ClientSession(read, write) as session:
await session.initialize()
tools = await session.list_tools()

list_tools() is the discovery check, and it is worth running for its own sake rather than folding it into a call. It caught a real defect: after adding filter parameters to a tool, the schema the server advertised still showed the old three, because the wrapper in the entrypoint had its own signature that had not been updated alongside the implementation. Discovery reported the truth; a call would have silently ignored the new arguments.

Once the same script passed against localhost, the deployed path was the identical script with two environment variables:

Terminal window
export AGENT_ARN="arn:aws:bedrock-agentcore:<region>:<account>:runtime/<id>"
export BEARER_TOKEN=$(./scripts/token.sh)

The acceptance test was running it from outside AWS with no IAM credentials in play, holding nothing but a bearer token. That is the property the architecture actually promises, and it is not proven by any test that runs inside the account.

Connecting Claude Code, and the OAuth flow it cannot use

Claude Code was the last rung of the ladder, and registering it is one command. The awkward part is the URL: the runtime is addressed by its own ARN, percent-encoded into the path (: becomes %3A, / becomes %2F).

Terminal window
ENCODED=$(python3 -c "import sys;print(sys.argv[1].replace(':','%3A').replace('/','%2F'))" "$AGENT_ARN")
URL="https://bedrock-agentcore.$REGION.amazonaws.com/runtimes/$ENCODED/invocations?qualifier=DEFAULT"
claude mcp add --transport http agentcore-hub "$URL" \
--header "Authorization: Bearer $(./scripts/token.sh)"

/mcp inside Claude Code then reports the server as connected, and claude mcp list does the same from a shell.

The reason for that --header, rather than letting the client authenticate properly, is the one limitation here with no current workaround. Claude Code can run a full OAuth flow against an MCP server, but it needs RFC 7591 dynamic client registration to register itself, and Cognito publishes no registration_endpoint. Registering without a header fails immediately:

✘ Failed to connect — Incompatible auth server: does not support dynamic client registration

And the two routes are mutually exclusive, which the error says outright once a token ages past its hour:

✘ Failed to connect — Server rejected the configured Authorization header (HTTP 401).
OAuth fallback is disabled when headers.Authorization is set.
Error detail: {"jsonrpc":"2.0","error":{"code":-32001,"message":"Token has expired"}}

So the practical shape is a static token that dies every hour and a re-registration script to mint a fresh one. The durable fix is a local stdio proxy holding the refresh token and injecting a current access token per request, which was not built here.

Worth separating the two halves of that, because it is easy to read as an architecture problem: the browser client doing authorization code with PKCE against the same endpoint has none of these difficulties. This is a client-side constraint, not an architectural one.

The deploy succeeded and the tool quietly returned nothing

Deployment via the starter toolkit worked on the first try. direct_code_deploy ships a zip to S3 rather than building a container, which meant no local Docker daemon was needed. An external client with a bearer token listed the tools and pulled real data.

Then the Japanese input path returned nothing at all.

The server normalises a Japanese disease name into English before querying the upstream API, because that API does not interpret Japanese. Locally it worked. Deployed, it returned zero results with no error, and CloudWatch showed nothing because the tool caught the exception and folded it into its own return value:

except Exception as exc:
return {"term": condition, "source": "passthrough", "warning": "..."}

That is a silent failure of the agent’s own making, and it is worth stating plainly: a tool that degrades gracefully without logging is a tool whose misconfiguration is invisible. Adding a logger.warning alongside the return, and surfacing the specific error instead of a generic note, made the real problem readable:

The same failure, logged and not loggedTwo chains of four boxes starting from the same Bedrock 403. In the upper chain the exception is only folded into the return value, so CloudWatch shows nothing and the tool returns zero results with no explanation. In the lower chain the exception is also logged, so CloudWatch names the denied IAM action and the fix is a single policy change.Caught and returned onlyBedrock returns403except: →return warningCloudWatch:nothingTool returns0 resultsCaught, returned, and loggedBedrock returns403logger.warning(type, message)CloudWatch: thedenied IAM actionOne-linepolicy fix
Same 403 either way. Logging it is what turns an unexplained empty result into a one-line fix.
User: arn:aws:sts::<account>:assumed-role/<execution-role>/... is not authorized to perform:
bedrock-mantle:CreateInference on resource: arn:aws:bedrock-mantle:<region>:<account>:project/default

bedrock-mantle:CreateInference. Not bedrock:InvokeModel, which is what the agent had granted on the execution role and what every instinct says to grant. The Messages-API Bedrock client uses a different action on a different resource ARN entirely.

Two related findings from the same afternoon. Mantle model IDs are bare and undated: anthropic.claude-haiku-4-5 resolves, while the dated form and the regional-prefix form both 404, because those belong to the legacy bedrock-runtime path. And model availability is regional in a way that mattered. I chose Tokyo to match where production would live; Claude Opus 4.8, 4.7 and Haiku 4.5 were available there, while Opus 5 and Sonnet 5 returned 404.

A 225x improvement that was measuring the wrong thing

To measure the design claim, the agent built both tool sets on the same server and ran the same question through each. The first run:

path tool calls wall time input tokens
per-service 3 48.5s 802,179
use-case 1 21.1s 3,559

225 times fewer input tokens, and the per-service path had also produced a wrong answer: it found one or two trials, hedged heavily, and suggested consulting a different registry entirely. That combination should have been the tell.

That result was too good, and the reason was in the tool signature. The agent’s per-service search tool accepted only query_cond, page_size and page_token. The real upstream API also accepts a location filter, a status filter, a phase filter and a field projection. None of them had been exposed, so the model was forced to pull unfiltered full records and drown in them.

That is not the per-service design failing. That is a strawman, and had it gone into the client deck it would have collapsed under the first question anyone competent would ask.

What a fair comparison measured

The fix was to mirror the upstream API’s full parameter surface in the per-service tool: location, status, phase, field projection, pagination. Nothing held back. Three runs, median reported:

path tool calls wall time input tokens tool-result bytes
per-service 2 31.2s 8,006 13,614
use-case 1 20.0s 3,542 4,133

2.3x fewer tokens and one round trip saved. Both paths now answer correctly.

I decided to present that number rather than soften it, and the reasoning behind it is the part I would keep from this exercise. The upstream API here is well designed: it filters and projects server-side, so a use-case tool has very little joining left to absorb. It is the best case for the per-service design, not the worst.

The pathology that motivated the architecture, fetching an entire project list to find one identifier and then requesting each record individually, happens with APIs that offer neither server-side filtering nor field projection. That describes a lot of internal systems and very few public ones.

What still holds when the upstream API is good

So the honest framing is that the gain scales with how bad the upstream API is. Three benefits survive regardless of that:

  • Japanese input works at all. The upstream API returns 0 results for a Japanese disease name and 14,811 for its English equivalent.
  • The search is auditable. The resolved vocabulary identifier comes back alongside the answer.
  • The filters cannot be forgotten, because they are Python rather than a model’s judgement.

One more thing fell out of the same investigation. The controlled-vocabulary lookup added for normalisation contributes nothing to search breadth, because the upstream API already expands English synonyms internally: two different terms for the same condition returned an identical 4,291 results. It stayed in the design for auditability, which is a real reason, rather than for the reason it was originally added.

Pitfalls worth knowing before you start

Symptom Cause
ModuleNotFoundError: No module named 'mcp.server.fastmcp' mcp 2.0.0 removed FastMCP. Pin <2 or migrate to MCPServer
403 mentioning bedrock-mantle:CreateInference The Messages-API Bedrock client does not use bedrock:InvokeModel
Model ID 404 on a dated or region-prefixed name Mantle IDs are bare and undated
401 with a token that looks valid Cognito access tokens carry client_id and no aud. Configure allowedClients, never allowedAudience
401 after adding a second app client Every client ID must be listed in allowedClients, followed by a redeploy
A tool returning empty results with clean logs An exception folded into a return value without also being logged
Incompatible auth server: does not support dynamic client registration Cognito publishes no registration_endpoint. Register the client with a static header instead

The auth row cost the most time to diagnose, because a token rejected for the wrong reason looks exactly like a token rejected for the right one.

Summary

Deploying a Python MCP server on AgentCore Runtime is straightforward once you know that OAuth is mandatory, that the current mcp package broke the class every sample imports, and that the Bedrock Messages API needs an IAM action whose name shares no prefix with the one you would guess.

The more useful lesson was about measurement. A benchmark comparing two designs is only as honest as the weaker implementation, and it is very easy to build the weaker one carelessly and read the resulting gap as evidence. The number that survived scrutiny was two orders of magnitude smaller and considerably more useful, because it pointed at the condition under which the design actually pays: the quality of the API underneath it.

References

Share this article