# 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.

- Source: https://oharu121.com/blog/bedrock-agentcore-mcp-server-cognito-oauth-benchmark/
- Published: 2026-08-25T22:29:02+09:00
- Tags: MCP, AWS, Python, AgentCore

---
**Key takeaways**

- AgentCore Runtime has no IAM SigV4 path for deployed MCP servers. OAuth is the only inbound auth, so an identity provider is a hard dependency rather than a preference.
- `AnthropicBedrockMantle` needs `bedrock-mantle:CreateInference`, not `bedrock:InvokeModel`. Granting the documented-looking action produces a 403 that never mentions Mantle unless you read it closely.
- Version 2.0.0 of the `mcp` package removed the `FastMCP` class that every current AWS sample imports.
- A use-case tool beat per-service tools by 225x until the comparison was made fair. The honest figure was 2.3x.
- Consolidating tools pays off in proportion to how poor the upstream API is. Against a well-designed API there is almost nothing left to absorb.

## 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.

```text
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.

*Figure — RequestPath: 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.

*Figure — ToolDesign: 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:

```text
「肥満症で日本で実施中の第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:

```python
from mcp.server.fastmcp import FastMCP
```

On a fresh install that fails:

```text
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.

*Figure — VerificationLadder: 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`:

```bash
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:

```python
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:

```bash
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`).

```bash
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:

```text
✘ 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:

```text
✘ 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:

```python
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:

*Figure — SilentFailure: Same 403 either way. Logging it is what turns an unexplained empty result into a one-line fix.*

```text
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

- [Deploy MCP servers in AgentCore Runtime, including the fixed host, port and path contract](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-mcp.html)
- [Authenticate and authorize with Inbound Auth and Outbound Auth](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/runtime-oauth.html)
- [RFC 7591, OAuth 2.0 Dynamic Client Registration Protocol, which Cognito does not implement](https://datatracker.ietf.org/doc/html/rfc7591)
- [RFC 9728, OAuth 2.0 Protected Resource Metadata, the document the 401 points at](https://datatracker.ietf.org/doc/html/rfc9728)
