> ## Documentation Index
> Fetch the complete documentation index at: https://www.truefoundry.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Govern AgentCore Agents calling MCP servers

> Route AWS Bedrock AgentCore agents through the TrueFoundry gateway so every MCP tool call carries a verifiable identity — the agent's, the end user's, or both.

You run agents on AWS Bedrock AgentCore and MCP servers on AgentCore too. This guide puts the TrueFoundry
gateway between them, so you control which agent may use which tool — and choose whether the end user's
identity reaches the tool at all.

## What you are setting up

Your agent runs on an AgentCore Runtime. Your MCP server runs on another one, reachable only through the
AgentCore invocation endpoint rather than the open internet. The TrueFoundry gateway sits between them: it
authenticates the agent, decides whether that agent may use this tool, and supplies the credential the MCP
runtime requires.

| Component               | Where it runs     | Its job in this setup                                                                      |
| ----------------------- | ----------------- | ------------------------------------------------------------------------------------------ |
| **Your agent**          | AgentCore Runtime | Calls tools. Exchanges the signed-in user's token for one that names the agent.            |
| **TrueFoundry gateway** | TrueFoundry       | Resolves the agent, enforces access policy, and authenticates upstream to the MCP runtime. |
| **Your MCP server**     | AgentCore Runtime | Serves tools. What it can attribute a call to depends on which mode you pick.              |
| **Okta**                | Your tenant       | Issues the tokens and holds the exchange policy.                                           |
| **AgentCore Identity**  | AWS               | Brokers the agent's token exchange with Okta and stores the client secret.                 |

The agent never holds a credential for the MCP runtime. It presents a token describing itself, and the
gateway is the only place an upstream credential lives.

<Note>
  **One inbound token, two upstream modes.** The agent sends the gateway *the same thing* either way: its own
  agent token, obtained by exchanging the user's login token. What differs is only how the gateway
  authenticates **upstream** to your MCP runtime — and therefore what your MCP server can see.
</Note>

***

## Mapping an AgentCore agent to a TrueFoundry agent

Three identities are in play. Keeping them distinct is what makes the governance in this guide possible.

| Identity                               | Where it lives                       | What it is for                                                                                                                                                                                                                              |
| -------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Workload identity** (AgentCore)      | Created automatically per runtime    | Lets your agent call AgentCore Identity to request a token. Internal to AWS — the gateway never sees it.                                                                                                                                    |
| **Okta service app** (`agent-service`) | Okta, as an OIDC service application | The agent's identity *in the token chain*. Its client id lands in the `cid` claim of the agent's token — and because that claim sits inside a signed token, the chain of authentication is cryptographically enforced rather than asserted. |
| **TrueFoundry agent** (`demo-agent`)   | TrueFoundry Agents Registry          | The governable principal. Tool access, policy and audit attach here.                                                                                                                                                                        |

The link between the last two is a claim mapping. Register Okta (the `agent-as` issuer) as an identity
provider in TrueFoundry with **name claim `cid`**, then register the agent with an
**identity-provider-backed identity** mapping `cid = agent-service` to `demo-agent`. A token arriving with
that `cid` now resolves to a named agent, and the gateway applies that agent's policy.

One Okta service application can back several runtimes — useful when they are instances of the same logical
agent. Give agents that need *different* tool access different service applications, since access is granted
against the resolved agent.

***

## Okta registrations you need

Three **custom** authorization servers, one per audience. The audience is what lets each hop confirm a token
was minted for it, rather than merely being valid somewhere.

| Authorization server | Audience            | Issues                                                                  |
| -------------------- | ------------------- | ----------------------------------------------------------------------- |
| `login-as`           | `api://chat-client` | the user's login token, at sign-in                                      |
| `agent-as`           | `api://agent-api`   | the agent's token — the exchange target, and what the gateway validates |
| `mcp-as`             | `api://mcp-api`     | the token your MCP runtime validates *(Okta OBO only)*                  |

<Warning>
  **Do not use the org authorization server.** A token exchange requires the authorization server that
  *issued* the subject token to be registered as a **trusted server** on the server performing the exchange.
  Okta's org authorization server cannot be named in that relationship — it is not selectable — and cannot
  carry a custom audience. Every hop that takes part in an exchange, **including the sign-in hop**, needs its
  own custom authorization server.
</Warning>

Applications, and the scope they trade in:

| Application        | Type                              | Role                                                                       |
| ------------------ | --------------------------------- | -------------------------------------------------------------------------- |
| `chat-client`      | OIDC web (auth code + PKCE)       | Where the user signs in, against `login-as`.                               |
| `agent-service`    | OIDC service (client credentials) | The agent's identity. Client of the exchange at `agent-as`; becomes `cid`. |
| `tfy-mcp-exchange` | OIDC service (client credentials) | The client the *gateway* uses for the second exchange *(Okta OBO only)*.   |

Define a real API scope on `agent-as` and `mcp-as`, and grant it in each server's access policy. The examples
below use `tool.invoke`; substitute the scopes your own MCP server expects. A scope is mandatory on every
exchange request, and OIDC scopes are refused, so there has to be an API scope to ask for.

<Note>
  **Turn DPoP off on the service applications.** AgentCore Identity and the gateway both authenticate to Okta
  with a client secret and do not generate DPoP proofs. If *Require Demonstrating Proof of Possession* is
  enabled, every token request from that client fails.
</Note>

***

## Choosing between the two upstream modes

Both modes give you the same governance at the gateway — the agent is named, policy is enforced, and the call
is audited. They differ in **what reaches your MCP server**.

|                                     | IAM SigV4                                                                | Okta OBO                                   |
| ----------------------------------- | ------------------------------------------------------------------------ | ------------------------------------------ |
| **Agent resolved at the gateway**   | yes                                                                      | yes                                        |
| **Gateway RBAC and audit**          | yes                                                                      | yes                                        |
| **User identity at the MCP server** | no                                                                       | yes — `sub`                                |
| **Per-user logic inside the tool**  | not possible                                                             | yes                                        |
| **MCP runtime inbound auth**        | IAM (SigV4)                                                              | JWT authorizer                             |
| **Okta work**                       | sign-in + one exchange                                                   | sign-in + two exchanges, trust chain       |
| **Good for**                        | shared reference data, internal lookups, tools that cannot validate JWTs | anything touching a specific person's data |

**Why you may end up with two MCP runtimes.** An AgentCore Runtime accepts exactly one inbound auth mode —
IAM or JWT, never both. If you want the same tools reachable under both modes, deploy the same MCP image
twice and register each runtime as its own MCP server. The tool code is identical; only the runtime's inbound
configuration differs.

***

## IAM SigV4 — agent identity stops at the gateway

The gateway signs its call to your MCP runtime with AWS SigV4, using a dedicated IAM principal. The runtime
authorizes that principal; no token travels with the request, so the MCP server sees no user and no agent.
Everything you want to control still happens — one hop earlier, at the gateway.

<Frame>
  <img src="https://mintcdn.com/truefoundry/I72HcHqF9rN4h9SZ/images/agentcore-sigv4-flow-light.svg?fit=max&auto=format&n=I72HcHqF9rN4h9SZ&q=85&s=c722f363a279b8d1883bfeec7567e838" alt="IAM SigV4 flow: the user signs in at login-as, the agent exchanges that token at agent-as so it carries the user in sub and the agent in cid, the TrueFoundry gateway resolves the agent and enforces policy, then signs its upstream call with AWS SigV4 — so neither the user nor the agent travels past the gateway and the MCP server sees only an AWS principal." className="block dark:hidden" width="800" height="560" data-path="images/agentcore-sigv4-flow-light.svg" />

  <img src="https://mintcdn.com/truefoundry/I72HcHqF9rN4h9SZ/images/agentcore-sigv4-flow-dark.svg?fit=max&auto=format&n=I72HcHqF9rN4h9SZ&q=85&s=31eac6d301385a48d846691322abdf5a" alt="IAM SigV4 flow: the user signs in at login-as, the agent exchanges that token at agent-as so it carries the user in sub and the agent in cid, the TrueFoundry gateway resolves the agent and enforces policy, then signs its upstream call with AWS SigV4 — so neither the user nor the agent travels past the gateway and the MCP server sees only an AWS principal." className="hidden dark:block" width="800" height="560" data-path="images/agentcore-sigv4-flow-dark.svg" />
</Frame>

### Set it up

<Steps>
  <Step title="Create login-as and agent-as, and the applications">
    Create the two custom authorization servers with their audiences, add the `tool.invoke` scope and an access
    policy to each, then create `chat-client` and `agent-service`. On `agent-service`, enable the
    **Token Exchange** grant under *Advanced → non-interactive grants* and turn *Require DPoP* off.
  </Step>

  <Step title="Register login-as as a trusted server on agent-as">
    On `agent-as` → *Trusted servers* → add `login-as`, then add a token-exchange rule letting `agent-service`
    exchange a `login-as` token for one audienced to `api://agent-api`, granting `tool.invoke`. Set the user
    condition so the signed-in user matches.
  </Step>

  <Step title="Create the AgentCore Identity credential provider">
    ```bash theme={"dark"}
    aws bedrock-agentcore-control create-oauth2-credential-provider \
      --name okta-agent-obo \
      --credential-provider-vendor CustomOauth2 \
      --oauth2-provider-config-input '{"customOauth2ProviderConfig":{
          "oauthDiscovery":{"discoveryUrl":"https://<org>.okta.com/oauth2/<agent-as-id>/.well-known/openid-configuration"},
          "clientId":"<agent-service client id>",
          "clientSecret":"<agent-service secret>",
          "clientAuthenticationMethod":"CLIENT_SECRET_BASIC",
          "onBehalfOfTokenExchangeConfig":{
            "grantType":"TOKEN_EXCHANGE",
            "tokenExchangeGrantTypeConfig":{"actorTokenContent":"NONE"}}}}'
    ```

    It points at `agent-as` — the exchange *target*. Nothing here names `login-as`: the subject issuer is
    established by the trusted-server relationship in Okta and by the agent runtime's authorizer.
  </Step>

  <Step title="Create the two runtimes">
    The agent runtime validates the login token and forwards the bearer. The MCP runtime takes **no authorizer at
    all** — omitting it is what selects IAM/SigV4 inbound.

    ```python theme={"dark"}
    # agent runtime
    authorizerConfiguration={"customJWTAuthorizer":{
        "discoveryUrl": LOGIN_AS_DISCOVERY,          # login-as
        "allowedAudience": ["api://chat-client"]}},
    requestHeaderConfiguration={"requestHeaderAllowlist":["Authorization"]}

    # MCP runtime — no authorizerConfiguration, no header allowlist
    protocolConfiguration={"serverProtocol":"MCP"}
    ```
  </Step>

  <Step title="Have the agent exchange the token and call the gateway">
    ```python theme={"dark"}
    # AgentCore pre-exchanges the validated login token and injects a workload token.
    # Read it PER REQUEST — a cached token attributes one user's actions to another.
    wat = (request.headers.get("X-Amz-Bedrock-AgentCore-Identity-WAT")
           or request.headers.get("WorkloadAccessToken"))

    agent_token = boto3.client("bedrock-agentcore").get_resource_oauth2_token(
        resourceCredentialProviderName="okta-agent-obo",
        oauth2Flow="ON_BEHALF_OF_TOKEN_EXCHANGE",
        workloadIdentityToken=wat,
        scopes=["tool.invoke"],                     # required; never "openid"
        audiences=["api://agent-api"],              # required; no default is assumed
        customParameters={                          # AgentCore's default value is refused by Okta
            "subject_token_type": "urn:ietf:params:oauth:token-type:access_token"},
    )["accessToken"]
    ```

    Send `agent_token` to the gateway as the bearer. Do not call `GetWorkloadAccessTokenForJWT` yourself —
    AgentCore has already done that step, and calling it again from inside the runtime fails.
  </Step>

  <Step title="Create the IAM principal the gateway signs with">
    A dedicated IAM user whose only permission is invoking that one runtime. Grant **both** actions — the gateway
    passes a caller id, and invoking on behalf of a named user is a separate action that a policy with only
    `InvokeAgentRuntime` will not cover.

    ```json theme={"dark"}
    {"Effect":"Allow",
     "Action":["bedrock-agentcore:InvokeAgentRuntime",
               "bedrock-agentcore:InvokeAgentRuntimeForUser"],
     "Resource":["<mcp-runtime-arn>","<mcp-runtime-arn>/*"]}
    ```

    Use a long-lived access key: the gateway's SigV4 support does not take a session token, so SSO or STS
    credentials cannot be used here.
  </Step>

  <Step title="Register everything in TrueFoundry">
    Add Okta (`agent-as` issuer) as an identity provider with name claim `cid`; register the agent with an
    identity-provider-backed identity mapping `cid = agent-service`; add the MCP server and make the agent an
    **MCP Server User** on it.

    ```yaml theme={"dark"}
    type: mcp-server/remote
    name: agentcore-mcp-sigv4
    url: https://bedrock-agentcore.<region>.amazonaws.com/runtimes/<url-encoded-runtime-arn>/invocations?qualifier=DEFAULT
    auth_data:
      type: aws-sigv4
      region: <region>
      auth:
        type: aws-access-key
        access_key_id:     tfy-secret://<tenant>:agentcore:AWS_ACCESS_KEY_ID
        secret_access_key: tfy-secret://<tenant>:agentcore:AWS_SECRET_ACCESS_KEY
    ```
  </Step>
</Steps>

<Warning>
  **Use the bare runtime ARN in the URL.** The endpoint is selected by the `?qualifier=` query parameter, so
  the ARN embedded in the path must be the **runtime** ARN. The AWS console shows the *endpoint* ARN
  (`…/runtime-endpoint/DEFAULT`) prominently, and pasting that produces a failure that looks like a permissions
  problem. The region must also match in three places: the URL, the ARN, and the gateway's SigV4 region field.
</Warning>

### What your MCP server receives

No `Authorization` header, and no claims — the request is authorized as an AWS principal. Your tool code
should handle that explicitly rather than falling back to a default user, so an unauthenticated call never
quietly looks like an authenticated one:

```python theme={"dark"}
user = subject_from_validated_token(ctx)   # None under SigV4
if user is None:
    return non_user_scoped_data()          # or refuse, per tool
```

Return only data that is safe for any caller the gateway has authorized. Per-user decisions are not possible
here — that is what Okta OBO adds.

***

## Okta OBO — user identity reaches the tool

Steps 1 and 2 are unchanged. Instead of signing with SigV4, the gateway performs a **second exchange** —
turning the agent's token into one audienced to your MCP runtime that still names the user.

<Frame>
  <img src="https://mintcdn.com/truefoundry/I72HcHqF9rN4h9SZ/images/agentcore-obo-flow-light.svg?fit=max&auto=format&n=I72HcHqF9rN4h9SZ&q=85&s=1d7b0b7747325ca1115e08ffae04b972" alt="Okta OBO flow: the first two hops are unchanged, then the TrueFoundry gateway performs a second token exchange at mcp-as as tfy-mcp-exchange, producing a token audienced to api://mcp-api that still names the user, which the MCP runtime validates and uses to scope its answer." className="block dark:hidden" width="800" height="560" data-path="images/agentcore-obo-flow-light.svg" />

  <img src="https://mintcdn.com/truefoundry/I72HcHqF9rN4h9SZ/images/agentcore-obo-flow-dark.svg?fit=max&auto=format&n=I72HcHqF9rN4h9SZ&q=85&s=9fa94483f1dbf70f8e686d9706d4e55e" alt="Okta OBO flow: the first two hops are unchanged, then the TrueFoundry gateway performs a second token exchange at mcp-as as tfy-mcp-exchange, producing a token audienced to api://mcp-api that still names the user, which the MCP runtime validates and uses to scope its answer." className="hidden dark:block" width="800" height="560" data-path="images/agentcore-obo-flow-dark.svg" />
</Frame>

### Set it up

Everything from IAM SigV4 still applies. This is what you add.

<Steps>
  <Step title="Create mcp-as and the exchange client">
    Add the `mcp-as` authorization server with audience `api://mcp-api` and the `tool.invoke` scope. Create
    `tfy-mcp-exchange` as an OIDC service application with the Token Exchange grant enabled and DPoP off.
  </Step>

  <Step title="Extend the trust chain and add the exchange rule">
    On `mcp-as` → *Trusted servers* → add `agent-as`. Then add a token-exchange rule permitting
    `tfy-mcp-exchange` to exchange an `agent-as` token for one audienced to `api://mcp-api`, granting
    `tool.invoke`.
  </Step>

  <Step title="Add the user claims you want downstream">
    On `mcp-as` → *Claims*, add `email` as an expression (`user.email`) included in the **access token**, with
    the condition set to **any scope**. A claim tied to the `email` or `profile` scope never appears, because the
    exchange requests only `tool.invoke` — the token validates and the claim is simply absent.
  </Step>

  <Step title="Give the MCP runtime a JWT authorizer and the header allowlist">
    ```python theme={"dark"}
    authorizerConfiguration={"customJWTAuthorizer":{
        "discoveryUrl": MCP_AS_DISCOVERY,            # mcp-as
        "allowedAudience": ["api://mcp-api"],
        "allowedScopes": ["tool.invoke"]}},
    requestHeaderConfiguration={"requestHeaderAllowlist":["Authorization"]}
    ```

    The allowlist is essential. AgentCore strips inbound headers by default, even when its authorizer has just
    validated the token — without it your MCP server receives no token at all, and Okta OBO behaves exactly like
    IAM SigV4 while looking like a failed exchange.
  </Step>

  <Step title="Switch the gateway's upstream auth">
    Register the second MCP server against the JWT runtime. No agent code changes — the agent sends the same
    token it sent under IAM SigV4.

    ```yaml theme={"dark"}
    type: mcp-server/remote
    name: agentcore-mcp-oauth
    url: https://bedrock-agentcore.<region>.amazonaws.com/runtimes/<url-encoded-runtime-arn>/invocations?qualifier=DEFAULT
    auth_data:
      type: oauth2
      grant_type: token_exchange        # subject_token = the inbound agent token
      provider: okta
      token_url: https://<org>.okta.com/oauth2/<mcp-as-id>/v1/token
      audience: api://mcp-api
      client_id: <tfy-mcp-exchange client id>
      client_secret: tfy-secret://<tenant>:agentcore:TFY_MCP_EXCHANGE_SECRET
      scopes: [tool.invoke]
    ```
  </Step>
</Steps>

### What your MCP server receives

| Claim   | Value              | What to do with it                            |
| ------- | ------------------ | --------------------------------------------- |
| `sub`   | the signed-in user | Scope every response to this person.          |
| `email` | their address      | Present only if you added the claim.          |
| `aud`   | `api://mcp-api`    | Minted for this server; reject anything else. |
| `scp`   | `[tool.invoke]`    | What was granted.                             |
| `cid`   | `tfy-mcp-exchange` | The exchanging client — see the note below.   |

Derive the caller from `sub` in the validated token, never from a tool argument. Tool arguments are produced
by the model, so a `user` parameter can be steered by prompt injection; a token cannot.

<Warning>
  **Verify the signature unless a validating layer guarantees it.** Behind an AgentCore runtime with a JWT
  authorizer, the signature, issuer, audience and scopes have already been checked before your code runs, so
  decoding the payload to read claims is enough. If your MCP server is reachable *without* such a layer,
  decoding without verifying accepts any self-signed JWT — a complete authentication bypass. Verify against the
  issuer's JWKS in that case.
</Warning>

<Note>
  **Where the agent's name lives.** The second exchange is performed by the gateway's client, so `cid` in the
  final token names `tfy-mcp-exchange` rather than your agent. The *user* survives the hop; the agent was
  resolved at the gateway and is recorded there. If your MCP server itself must make agent-aware decisions,
  have Okta emit an actor claim (`act`) or a custom claim carrying the agent id.
</Note>

***

## What you can govern

Because the agent resolves to a named principal at the gateway, policy lives there rather than in agent code
— and applies identically in both modes.

| Control                                | How it works                                                                                                                          | IAM SigV4 | Okta OBO     |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------- | --------- | ------------ |
| **Which agents may reach a server**    | Collaborators on the MCP server. An agent that is not one is refused, whatever valid token it presents.                               | yes       | yes          |
| **What it may do there**               | The `MCP Server User` role calls tools; `MCP Server Manager` changes configuration.                                                   | yes       | yes          |
| **Which tools**                        | A virtual MCP server exposes a curated subset, so an agent gets three tools rather than thirty.                                       | yes       | yes          |
| **Credential isolation**               | The IAM key or client secret lives in the gateway. Agents hold no upstream credential, and rotation is one place.                     | yes       | yes          |
| **Attribution**                        | Calls are recorded against the resolved agent — which is why the `cid` mapping matters even when no user is involved.                 | agent     | agent + user |
| **Per-user authorization in the tool** | Your MCP server applies its own rules to `sub` — ownership checks, row filtering, per-person limits.                                  | no        | yes          |
| **Least privilege per call**           | The upstream token is bound to one audience and scope set, and expires. Compare a shared API key: unscoped, unattributed, long-lived. | n/a       | yes          |

Changing mode is gateway configuration, not an agent change: moving a server from SigV4 to on-behalf-of
touches the server registration and the MCP runtime's inbound auth, and the agent keeps sending the same
token.

***

## Verifying what actually arrived

Do not assume identity propagated — assert it. Add a diagnostic tool that returns what your MCP server
genuinely sees, and keep it in place:

```python theme={"dark"}
@mcp.tool()
def whoami(ctx) -> dict:
    claims = validated_claims(ctx)          # {} when no token arrives
    return {"authenticated": bool(claims),
            "sub": claims.get("sub"),  "aud": claims.get("aud"),
            "cid": claims.get("cid"),  "scope": claims.get("scp"),
            "act": claims.get("act"),  "iss": claims.get("iss")}
```

| Field           | IAM SigV4 | Okta OBO           |
| --------------- | --------- | ------------------ |
| `authenticated` | false     | true               |
| `sub`           | absent    | the signed-in user |
| `aud`           | absent    | `api://mcp-api`    |
| `scope`         | absent    | `[tool.invoke]`    |

Ask your agent *"who am I?"* against each server. Under IAM SigV4 the answer names no user — that is correct,
not a failure. Under Okta OBO it names the signed-in person. Seeing the two side by side is the clearest
evidence that delegation is real.

It also pays to log identity at every hop you own — the client, the agent's entry point, each exchange
(request *and* result), and the MCP server — recording `sub`, `aud`, `cid` and scopes, with tokens redacted.
Log the exchange request *before* you make it: a failure logged only on success produces silence exactly when
you need the detail.

***

## Troubleshooting

When an exchange fails, read **Okta's System Log** first. AgentCore and the gateway surface only a generic
HTTP error; the log records the precise reason under `outcome.reason` and echoes what was actually sent,
which also confirms whether your change took effect.

| Symptom                                                           | Cause                                                                                                                                            |
| ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------ |
| MCP server sees no token, though authentication clearly succeeded | `Authorization` is not in the runtime's header allowlist. AgentCore strips inbound headers by default.                                           |
| `invalid_subject_token`                                           | The issuing authorization server is not a trusted server on the exchanging one — or the sign-in hop is still using the org authorization server. |
| `invalid_subject_token_type`                                      | Subject token type is not `…:access_token`. AgentCore's default value is refused, so set it explicitly.                                          |
| `openid_not_allowed_token_exchange`                               | An OIDC scope was requested. Ask for a real API scope defined on the target server.                                                              |
| Exchange succeeds but a configured claim is missing               | The claim is conditional on a scope the exchange does not request. Set it to *any scope*.                                                        |
| Runtime rejects a valid-looking token                             | Audience mismatch — an access token's `aud` is the authorization server's audience, not a client id.                                             |
| Gateway returns 403                                               | Check policy before credentials: is the agent a collaborator on this server, and did `cid` resolve to a registered agent?                        |
| A credential fix appears to have no effect                        | Upstream credentials bind when the MCP server entry is saved. Re-save the entry after rotating a secret.                                         |
| `403 AccessDeniedException` from AgentCore                        | Overloaded — read the response body. The same status covers an IAM denial, an unresolvable resource ARN, and a SigV4 signature mismatch.         |

<Note>
  **A habit worth keeping.** If a change that *should* have altered the failure produces a byte-identical
  error, stop forming new hypotheses and first prove your change reached the component under test — a cached
  upstream credential or a runtime still on its previous version will absorb every fix you try.
</Note>
