Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions docs/MCP_REGISTRY_DISCOVERY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
# MCP Server Discovery from AWS Agent Registry

Discover MCP servers from an [AWS Agent Registry](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/registry.html)
at agent runtime and **auto-connect** to them, so the registry's approved MCP
records become live, callable tools on your FAST agent — with **no
DynamoDB, no UI, and no per-user preferences**. This is a deliberately
lightweight feature: enable it, point it at a registry, and the agent does the
rest on each request.

## How it differs from Dynamic MCP Servers

FAST has two, independent ways to add external MCP servers. Pick whichever fits;
they do not depend on each other.

| | **MCP Registry Discovery** (this doc) | **[Dynamic MCP Servers](MCP_SERVERS.md)** |
|---|---|---|
| Source of servers | An AWS Agent Registry, queried at **runtime** | A catalog you declare at **deploy time** in `config.yaml` / `tfvars` |
| How servers connect | Direct Strands `MCPClient`s on the agent | AgentCore **Gateway targets** |
| Per-user on/off | No (agent-wide) | Yes (DynamoDB-backed preferences + settings UI) |
| Extra infrastructure | One IAM statement + two env vars | DynamoDB table, `mcp-prefs` Lambda, API routes, frontend dialog |
| Best for | "Let the agent use whatever the org has published" | "Curated, per-user-toggleable set of servers" |

## Overview

When enabled, on each request the agent:

1. Lists the registry's **Approved** `recordType == "MCP"` records
(`list_discoverable_registry_records`, paginated).
2. Fetches their full descriptors in one batch call
(`batch_get_discoverable_registry_record`) and reads each server's
streamable-HTTP endpoint from `mcpServer.remotes[0].url`.
3. Builds a live Strands `MCPClient` for each **public streamable-HTTP** server
and adds it to the agent's tools, alongside the Gateway client and Code
Interpreter.

```
┌──────────────────────────┐
Agent Runtime ──────▶│ AWS Agent Registry │ list_discoverable_registry_records
(basic_agent.py) │ (agent-registry API) │ batch_get_discoverable_registry_record
│ └──────────────────────────┘
│ build_registry_mcp_clients() → remotes[0].url
▼
┌───────────────┐ streamable HTTP ┌───────────────────┐
│ Strands │────────────────────▶│ Discovered MCP │
│ MCPClient(s) │ │ Server (public) │
└───────────────┘ └───────────────────┘
```

## Quick Start

### CDK (`infra-cdk/config.yaml`)

```yaml
backend:
mcp_registry:
enabled: true
registry_id: arn:aws:agent-registry:us-east-1:123456789012:registry/my-registry
```

Deploy with `cdk deploy`.

### Terraform (`terraform.tfvars`)

```hcl
mcp_registry = {
enabled = true
registry_id = "arn:aws:agent-registry:us-east-1:123456789012:registry/my-registry"
}
```

Deploy with `terraform apply`.

## Configuration Reference

| Field | Required | Default | Description |
|-------|----------|---------|-------------|
| `enabled` | No | `false` | Master switch. When false the feature is completely inert (no IAM, no env, no runtime calls). |
| `registry_id` | When enabled | `""` | ARN or id of the AWS Agent Registry. A full ARN scopes the IAM grant to that registry; a bare id falls back to the account/region `registry/*` wildcard. |

Validation is **fail-loud**: enabling the feature without a `registry_id` fails
at synth/plan time (CDK `config-manager` and the Terraform variable validation).

## Environment Variables (set by the infrastructure)

| Variable | Description |
|----------|-------------|
| `MCP_REGISTRY_DISCOVERY_ENABLED` | `"true"` activates discovery in the agent runtime. |
| `MCP_REGISTRY_ID` | ARN or id of the registry to discover from. |
| `AWS_REGION` / `AWS_DEFAULT_REGION` | Region of the registry (already set for the runtime). |

## IAM

When enabled, the agent runtime execution role gets two read-only statements.
**The IAM action names differ from the API names**: the `BatchGetDiscoverableRegistryRecord`
API is authorized by the permission-only action `agent-registry:GetDiscoverableRegistryRecord`
on the **record** resource, while `List`/`Search` authorize on the **registry** resource.

```json
[
{
"Sid": "AgentRegistryDiscoveryList",
"Effect": "Allow",
"Action": [
"agent-registry:ListDiscoverableRegistryRecords",
"agent-registry:SearchDiscoverableRegistryRecords"
],
"Resource": "arn:aws:agent-registry:<region>:<account>:registry/<registryId>"
},
{
"Sid": "AgentRegistryDiscoveryGetRecord",
"Effect": "Allow",
"Action": "agent-registry:GetDiscoverableRegistryRecord",
"Resource": "arn:aws:agent-registry:<region>:<account>:registry/<registryId>/record/*"
}
]
```

> Common pitfall: granting `agent-registry:BatchGetDiscoverableRegistryRecord`
> (matching the API name) is a **no-op** — that is not a real IAM action, so
> record reads fail with `AccessDenied`. Use `GetDiscoverableRegistryRecord`.

## Namespace note

The data-plane discovery APIs (`ListDiscoverableRegistryRecords`,
`BatchGetDiscoverableRegistryRecord`) live under the **`agent-registry`**
namespace. The public-preview **`bedrock-agentcore`** namespace does **not**
expose these APIs and is scheduled for discontinuation on **2026-09-17**. This
feature uses `boto3.client("agent-registry")` accordingly. If you created your
registry under the old namespace, migrate it first — see the
[registry migration guide](https://docs.aws.amazon.com/bedrock-agentcore/latest/devguide/registry-faq.html).

## Behavior & Limits

- **Only Approved records** are returned by the discovery APIs; Draft /
Pending / Rejected / Deprecated records are never connected.
- **v1 connects public `streamable-http` servers only.** Records using another
transport, or requiring authentication, are logged and skipped. Per-server
OAuth is a documented follow-up (the [Dynamic MCP Servers](MCP_SERVERS.md)
Gateway path already supports OAUTH M2M today if you need auth now).
- **Fail-loud on misconfiguration**: enabled + no `registry_id` raises at
synth/plan time.
- **Fail-soft at runtime**: if the registry is unreachable, access is denied, or
an individual record is malformed, that server is skipped with a logged
warning and the agent keeps responding on its remaining tools.
- **Per-request discovery**: the tool list reflects the registry's current
approved records on each new agent invocation.

## Tool Naming

Each discovered server is connected with a Strands client prefix derived from
its record name: `registry_<slug>`, where `<slug>` is the lowercased name with
non-alphanumeric runs collapsed to underscores. Its tools therefore appear to
the agent as `registry_<slug>_<tool_name>`.

## Troubleshooting

| Issue | Cause | Fix |
|-------|-------|-----|
| No discovered tools appear | Feature disabled, or registry has no Approved MCP records | Set `enabled: true` + `registry_id`; approve records in the registry |
| Synth/plan fails on `registry_id` | `enabled` true but `registry_id` empty | Provide the registry ARN/id |
| A known server isn't connected | Non-HTTP transport, auth-required, or missing `remotes[0].url` | Check the record's descriptor; v1 connects public streamable-HTTP only |
| `AccessDeniedException` in logs | Role lacks discovery permissions or registry not readable | Confirm the `AgentRegistryDiscoveryAccess` statement covers the registry |
| APIs return `ValidationException` for the namespace | Registry created under deprecated `bedrock-agentcore` namespace | Migrate the registry to `agent-registry` |

## Files

- `patterns/strands-single-agent/tools/mcp_registry.py` — discovery + client builder
- `patterns/strands-single-agent/basic_agent.py` — wires discovered clients into the agent
- `infra-cdk/lib/utils/config-manager.ts`, `infra-cdk/lib/backend-construct.ts`, `infra-cdk/config.yaml` — CDK config, IAM, env
- `infra-terraform/modules/backend/{variables,runtime}.tf`, `infra-terraform/{variables,main}.tf`, `terraform.tfvars.example` — Terraform parity
9 changes: 9 additions & 0 deletions infra-cdk/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,15 @@ backend:
ltm_top_k: 10 # Number of facts to retrieve per turn (default: 10)
ltm_relevance_score: 0.3 # Minimum similarity threshold for retrieval (default: 0.3)

# MCP server discovery + auto-connect from an AWS Agent Registry.
# Lightweight: no DynamoDB, no UI, no per-user preferences. When enabled, the
# agent lists the registry's Approved MCP records at runtime and connects to
# each public streamable-HTTP server as a live MCP client (their tools become
# directly callable). See docs/MCP_REGISTRY_DISCOVERY.md.
mcp_registry:
enabled: false # Default off. Set true to activate discovery + auto-connect.
registry_id: "" # ARN or id of the AWS Agent Registry. Required when enabled.

# VPC configuration - required when network_mode is VPC
# Your VPC must have the necessary VPC endpoints for AWS services.
# See docs/DEPLOYMENT.md for the full list of required VPC endpoints.
Expand Down
50 changes: 50 additions & 0 deletions infra-cdk/lib/backend-construct.ts
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,50 @@ export class BackendConstruct extends Construct {
})
)

// Add AWS Agent Registry discovery access (only when the feature is enabled).
// Grants read-only discovery of the registry's Approved MCP records so the
// agent can list them and fetch their descriptors to auto-connect at runtime.
// Namespace note: the data-plane discovery APIs live under `agent-registry`
// (the older `bedrock-agentcore` namespace is deprecated after 2026-09-17).
if (config.backend.mcp_registry.enabled) {
const registryId = config.backend.mcp_registry.registry_id
// Resource ARNs differ by action per the AWS Agent Registry authorization
// reference: List/Search authorize on the *registry* resource, while the
// BatchGetDiscoverableRegistryRecord API is authorized by the permission-
// only action agent-registry:GetDiscoverableRegistryRecord on the *record*
// resource (registry/<id>/record/*). Note the API name and the IAM action
// name differ — granting "BatchGetDiscoverableRegistryRecord" is a no-op
// and yields AccessDenied on record reads.
const registryArn = registryId.startsWith("arn:")
? registryId
: `arn:aws:agent-registry:${this.region}:${this.account}:registry/*`
const recordArn = registryId.startsWith("arn:")
? `${registryId}/record/*`
: `arn:aws:agent-registry:${this.region}:${this.account}:registry/*/record/*`
// Registry-level: enumerate + natural-language search.
agentRole.addToPolicy(
new iam.PolicyStatement({
sid: "AgentRegistryDiscoveryList",
effect: iam.Effect.ALLOW,
actions: [
"agent-registry:ListDiscoverableRegistryRecords",
"agent-registry:SearchDiscoverableRegistryRecords",
],
resources: [registryArn],
})
)
// Record-level: read full descriptors via BatchGet (authorized by the
// permission-only GetDiscoverableRegistryRecord action on the record ARN).
agentRole.addToPolicy(
new iam.PolicyStatement({
sid: "AgentRegistryDiscoveryGetRecord",
effect: iam.Effect.ALLOW,
actions: ["agent-registry:GetDiscoverableRegistryRecord"],
resources: [recordArn],
})
)
}

// Environment variables for the runtime
const envVars: { [key: string]: string } = {
AWS_REGION: stack.region,
Expand All @@ -381,6 +425,12 @@ export class BackendConstruct extends Construct {
// See config.yaml: ltm_top_k and ltm_relevance_score.
LTM_TOP_K: String(config.backend.ltm_top_k),
LTM_RELEVANCE_SCORE: String(config.backend.ltm_relevance_score),
// Discover + auto-connect MCP servers from an AWS Agent Registry (opt-in).
// When enabled, the agent lists the registry's Approved MCP records and
// connects to each public streamable-HTTP server at runtime. See
// config.yaml: mcp_registry and docs/MCP_REGISTRY_DISCOVERY.md.
MCP_REGISTRY_DISCOVERY_ENABLED: config.backend.mcp_registry.enabled ? "true" : "false",
MCP_REGISTRY_ID: config.backend.mcp_registry.registry_id,
}

// Add claude-agent-sdk specific environment variable
Expand Down
34 changes: 34 additions & 0 deletions infra-cdk/lib/utils/config-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,29 @@ export interface AppConfig {
* Maps to the relevance_score parameter of RetrievalConfig. Defaults to 0.3.
*/
ltm_relevance_score: number
/**
* Discover and auto-connect MCP servers from an AWS Agent Registry.
* Lightweight: no DynamoDB, no UI, no per-user preferences. Defaults to disabled.
*/
mcp_registry: McpRegistryConfig
}
}

/**
* Runtime MCP-server discovery from an AWS Agent Registry.
*
* When enabled, the agent lists the registry's Approved `recordType=MCP`
* records and auto-connects to each public streamable-HTTP server as a live
* MCP client. Discovery happens at agent runtime, so no servers are declared
* at deploy time and no gateway targets are created.
*/
export interface McpRegistryConfig {
/** Master switch. When false (default) the feature is completely inert. */
enabled: boolean
/** ARN or id of the AWS Agent Registry to discover records from. Required when enabled. */
registry_id: string
}

export class ConfigManager {
private config: AppConfig

Expand Down Expand Up @@ -137,6 +157,16 @@ export class ConfigManager {
}
}

// Validate MCP registry discovery configuration.
// Fail loud: enabling discovery without a registry id is a deploy-time mistake.
const mcpRegistryEnabled = parsedConfig.backend?.mcp_registry?.enabled === true
const mcpRegistryId = (parsedConfig.backend?.mcp_registry?.registry_id ?? "").trim()
if (mcpRegistryEnabled && !mcpRegistryId) {
throw new Error(
`backend.mcp_registry.registry_id is required in ${configPath} when backend.mcp_registry.enabled is true.`
)
}

return {
stack_name_base: stackNameBase,
admin_user_email: parsedConfig.admin_user_email || null,
Expand All @@ -149,6 +179,10 @@ export class ConfigManager {
use_long_term_memory: parsedConfig.backend?.use_long_term_memory === true,
ltm_top_k: parsedConfig.backend?.ltm_top_k ?? 10,
ltm_relevance_score: parsedConfig.backend?.ltm_relevance_score ?? 0.3,
mcp_registry: {
enabled: mcpRegistryEnabled,
registry_id: mcpRegistryId,
},
},
}
} catch (error) {
Expand Down
3 changes: 3 additions & 0 deletions infra-terraform/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -95,5 +95,8 @@ module "backend" {
throttling_rate_limit = local.api_throttling_rate_limit
throttling_burst_limit = local.api_throttling_burst_limit

# MCP server discovery + auto-connect from an AWS Agent Registry (opt-in)
mcp_registry = var.mcp_registry

depends_on = [module.cognito, module.amplify_hosting]
}
43 changes: 43 additions & 0 deletions infra-terraform/modules/backend/runtime.tf
Original file line number Diff line number Diff line change
Expand Up @@ -345,6 +345,45 @@ data "aws_iam_policy_document" "runtime_policy" {
"arn:aws:bedrock-agentcore:${local.region}:${local.account_id}:workload-identity-directory/*"
]
}

# AgentRegistryDiscovery: read-only discovery of an AWS Agent Registry's
# Approved MCP records so the agent can list them and fetch descriptors to
# auto-connect at runtime. Only emitted when the feature is enabled.
# Authorization model (per the AWS Agent Registry auth reference):
# - List/Search authorize on the *registry* resource.
# - BatchGetDiscoverableRegistryRecord (the API) is authorized by the
# permission-only action agent-registry:GetDiscoverableRegistryRecord on
# the *record* resource (registry/<id>/record/*). The IAM action name
# differs from the API name — granting "BatchGet..." is a no-op and yields
# AccessDenied on record reads.
# Namespace note: these APIs live under `agent-registry` (the older
# `bedrock-agentcore` namespace is deprecated after 2026-09-17).
dynamic "statement" {
for_each = var.mcp_registry.enabled ? [1] : []
content {
sid = "AgentRegistryDiscoveryList"
effect = "Allow"
actions = [
"agent-registry:ListDiscoverableRegistryRecords",
"agent-registry:SearchDiscoverableRegistryRecords"
]
resources = [
startswith(var.mcp_registry.registry_id, "arn:") ? var.mcp_registry.registry_id : "arn:aws:agent-registry:${local.region}:${local.account_id}:registry/*"
]
}
}

dynamic "statement" {
for_each = var.mcp_registry.enabled ? [1] : []
content {
sid = "AgentRegistryDiscoveryGetRecord"
effect = "Allow"
actions = ["agent-registry:GetDiscoverableRegistryRecord"]
resources = [
startswith(var.mcp_registry.registry_id, "arn:") ? "${var.mcp_registry.registry_id}/record/*" : "arn:aws:agent-registry:${local.region}:${local.account_id}:registry/*/record/*"
]
}
}
}

resource "aws_iam_role_policy" "runtime" {
Expand Down Expand Up @@ -478,6 +517,10 @@ resource "aws_bedrockagentcore_agent_runtime" "main" {
MEMORY_ID = aws_bedrockagentcore_memory.main.id
STACK_NAME = var.stack_name_base
GATEWAY_CREDENTIAL_PROVIDER_NAME = "${var.stack_name_base}-runtime-gateway-auth"
# MCP server discovery + auto-connect from an AWS Agent Registry (opt-in).
# See modules/backend/variables.tf: mcp_registry and docs/MCP_REGISTRY_DISCOVERY.md.
MCP_REGISTRY_DISCOVERY_ENABLED = var.mcp_registry.enabled ? "true" : "false"
MCP_REGISTRY_ID = var.mcp_registry.registry_id
},
# claude-agent-sdk patterns require CLAUDE_CODE_USE_BEDROCK=1
local.is_claude_agent_sdk ? { CLAUDE_CODE_USE_BEDROCK = "1" } : {}
Expand Down
Loading
Loading