Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# Cross-Border KYC Agent — Amazon Bedrock + LangGraph + OpenRegistry MCP

This example demonstrates an agent built with **Amazon Bedrock** (Anthropic Claude Sonnet 4.5) and **LangGraph** that connects to the **[OpenRegistry](https://openregistry.sophymarine.com) MCP server** to perform live **cross-border Know-Your-Business (KYB) / Beneficial-Ownership (UBO) chain walking** across **27 national company registries** (UK Companies House, Germany Handelsregister, France Sirene+RNE, Italy InfoCamere via EU BRIS, Spain BORME, Korea OPENDART, plus 21 more).

**Functionality:**

1. **Search a company** in any of the 27 national registries by name or local company-number format.
2. **Pull the statutory profile, officers, and Persons with Significant Control (PSC / UBO)** for that entity, with every field name preserved verbatim from the upstream government register.
3. **Walk the corporate-ownership chain across borders** — when a PSC is itself a corporate entity, the agent recurses into that entity's home jurisdiction and continues until it reaches an individual, an AML-gated register (CJEU C-37/20 — DE/ES/IT/NL/LU/AT/MT/PT), or a configurable depth cap.
4. **Surface AML gates honestly** — when the upstream register returns HTTP `501 alternative_url`, the agent reports the statutory channel rather than substituting commercial-aggregator data.
5. **Cite every fact** to the registry + identifier, so the answer is auditable back to the government source.

## All 30 jurisdictions covered

The agent talks to a single MCP endpoint, but that endpoint covers every jurisdiction below. Each row is the native registry name + ISO code + a sample entity for testing. Eight EU jurisdictions have a CJEU C-37/20–restricted UBO register flagged with ⚠ — the agent surfaces the `alternative_url` honestly rather than substituting aggregator data.

| ISO | Country | Native registry | Sample entity |
|---|---|---|---|
| `GB` | United Kingdom | Companies House | Tesco PLC (00445790) |
| `IE` | Ireland | Companies Registration Office (CRO) | Apple Operations International Ltd (462571) |
| `FR` | France | INSEE Sirene + RNE | L'Oréal SA (552120222) |
| `DE` ⚠ | Germany | Handelsregister | Deutsche Bank AG (HRB 30000) |
| `IT` ⚠ | Italy | Registro delle imprese (via EU BRIS) | Ferrari NV |
| `ES` ⚠ | Spain | BORME (Boletín Oficial del Registro Mercantil) | Inditex SA |
| `NL` ⚠ | Netherlands | KVK Handelsregister | ASML Holding NV (33002587) |
| `BE` | Belgium | KBO/BCE (Crossroads Bank for Enterprises) | Anheuser-Busch InBev SA/NV (0417499106) |
| `PL` | Poland | KRS (Krajowy Rejestr Sądowy) | PKO Bank Polski SA (0000033057) |
| `CZ` | Czechia | ARES | ČEZ a.s. (45274649) |
| `FI` | Finland | PRH (Patentti- ja rekisterihallitus, YTJ) | Nokia Oyj (0112038-9) |
| `NO` | Norway | Brønnøysundregistrene (Enhetsregisteret) | Equinor ASA (923609016) |
| `IS` | Iceland | Fyrirtækjaskrá (Skatturinn) | Marel hf. (4202695199) |
| `CH` | Switzerland | Zefix (Federal Registry of Commerce) | Nestlé SA (CHE-105.916.057) |
| `LI` | Liechtenstein | Handelsregister Liechtenstein (Amt für Justiz) | LGT Bank AG (FL-0001.090.135-8) |
| `MC` | Monaco | RCI (Répertoire du Commerce et de l'Industrie) | Société des Bains de Mer SA |
| `IM` | Isle of Man | IoM Companies Registry | (any IoM 2006 Act company) |
| `CY` | Cyprus | DRCOR (Department of Registrar of Companies) | Bank of Cyprus PCL (HE190) |
| `KR` | South Korea | OPENDART (FSS Electronic Disclosure System) | Samsung Electronics (00126380) |
| `TW` | Taiwan | GCIS (Ministry of Economic Affairs) | TSMC (22099131) |
| `HK` | Hong Kong SAR | Companies Registry | HSBC Holdings plc (HK branch) |
| `MY` | Malaysia | SSM (Suruhanjaya Syarikat Malaysia) | Maybank Bhd (199301015245) |
| `AU` | Australia | ABR (ABN Lookup) | BHP Group Limited (49004028077) |
| `NZ` | New Zealand | NZ Companies Office | Fonterra Co-operative Group Ltd (9429038949149) |
| `CA` | Canada (federal) | Corporations Canada (CBCA, ISED) | (any federal CBCA company) |
| `CA-BC` | Canada · British Columbia | OrgBook BC | (any BC Limited Company) |
| `CA-NT` | Canada · Northwest Territories | CROS-RSEL (NWT Department of Justice) | (NT-incorporated companies) |
| `MX` | Mexico | PSM (Sistema Electrónico de Publicaciones de Sociedades Mercantiles) | (any S.A. de C.V.) |
| `KY` 💳 | Cayman Islands | CIMA (Regulated Entities Register) | (paid-tier only — anon/Free → 402) |
| `RU` | Russia | ЕГРЮЛ / ЕГРИП (FNS Federal Tax Service) + ГИР БО | Сбербанк (1027700132195) |

⚠ = CJEU C-37/20-restricted UBO register (DE / ES / IT / NL plus LU / AT / MT / PT). The agent surfaces `alternative_url` for these.
💳 = paid-tier only (anonymous + Free tiers receive HTTP 402).

The full per-jurisdiction capability matrix (which tools each registry supports, native ID format, quirks) is callable at runtime — ask the agent to run `list_jurisdictions` and it will return the live matrix.

## Why MCP (and not a custom tool function)?

OpenRegistry's tool surface — about 30 tools across the 27 registries — updates as new countries come online. Wiring it as a remote Streamable-HTTP MCP server means the agent **discovers tools at runtime** via the MCP `tools/list` RPC, so this notebook stays small and you don't have to update Python wrappers when the registry grows.

## Prerequisites

You will need the following before running this notebook. We use the `us-west-2` Region by default; for available Regions, see [Amazon Bedrock endpoints and quotas](https://docs.aws.amazon.com/general/latest/gr/bedrock.html).

- A valid AWS account.
- An AWS IAM role with permission to invoke Bedrock models. If you're running on a SageMaker notebook instance you'll also need permissions to manage SageMaker resources. Administrator access works.
- Access to **Anthropic Claude Sonnet 4.5** (or Sonnet 3.5 / Haiku as a substitute) enabled in Amazon Bedrock — see [Access foundation models](https://docs.aws.amazon.com/bedrock/latest/userguide/model-access.html).
- **No OpenRegistry API key needed** — the notebook uses the free anonymous tier (20 req/min/IP, 3-country fan-out). For higher rate limits or batch onboarding, complete the OAuth 2.1 flow at [openregistry.sophymarine.com/account](https://openregistry.sophymarine.com/account) and pass the token via `OPENREGISTRY_TOKEN`.

## Run

Open `bedrock_openregistry_kyc_agent.ipynb` in Jupyter or SageMaker. Run cells top-to-bottom. The final cell asks Claude on Bedrock to walk Revolut Ltd's PSC chain — you'll see ~5–10 live MCP tool calls against UK Companies House (and any cross-border parents the chain hits) before Claude returns a cited summary.

## What's covered (and what's not)

**Covered**: company profile, officers, PSC/UBO, shareholders, charges, filings index, raw filing documents (PDF / iXBRL / XBRL), statutory accounts, name-availability, address standardisation across 27 jurisdictions.

**Not covered**: credit scores, sanctions list screening, PEP screening, US private-company financials. OpenRegistry is a passthrough to the *registry* — for those layers, integrate a separate sanctions-list MCP or Bedrock Action Group.

## Costs

Bedrock model invocations are billed per the Bedrock price list. OpenRegistry is free on the anonymous tier; paid tiers ($9–$29/mo) buy higher rate limits and cross-border fan-out, but no data is paywalled.

## License

This sample is licensed under MIT-0 (matching the rest of `aws-samples/amazon-bedrock-samples`). OpenRegistry is a platform by [Sophymarine](https://sophymarine.com); their docs are CC-BY-4.0.
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
{
"cells": [
{
"cell_type": "markdown",
"id": "00-title",
"metadata": {},
"source": [
"# Cross-Border KYC Agent — Amazon Bedrock + LangGraph + OpenRegistry MCP\n",
"\n",
"Walk a company's beneficial-ownership chain across **27 national company registries** in a single conversation, using Amazon Bedrock (Anthropic Claude) as the planner and the [OpenRegistry](https://openregistry.sophymarine.com) hosted MCP server as the data source.\n",
"\n",
"OpenRegistry is a free, hosted Streamable-HTTP MCP server that proxies UK Companies House, Germany Handelsregister, France Sirene+RNE, Italy InfoCamere via EU BRIS, Spain BORME, Korea OPENDART, plus 21 more — every tool call is a real-time query against the upstream government API and the response is returned **unmodified** (every field name, status string, and raw filing byte preserved verbatim).\n",
"\n",
"**By the end of this notebook you'll be able to:**\n",
"\n",
"- Connect Bedrock-Claude to a remote Streamable-HTTP MCP server using `langchain-mcp-adapters`.\n",
"- Build a LangGraph ReAct agent that auto-discovers ~30 OpenRegistry tools at runtime.\n",
"- Walk a UK company's PSC chain across borders, with every fact cited back to the upstream government register.\n",
"- Handle CJEU C-37/20 access-restricted UBO registers (DE / ES / IT / NL / LU / AT / MT / PT) honestly, by surfacing the `alternative_url` instead of substituting aggregator data."
]
},
{
"cell_type": "markdown",
"id": "01-install",
"metadata": {},
"source": [
"## 1. Installation"
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "02-pip",
"metadata": {},
"outputs": [],
"source": [
"%%capture\n",
"%pip install -U langchain-aws langchain-mcp-adapters langgraph mcp boto3 nest_asyncio python-dotenv"
]
},
{
"cell_type": "markdown",
"id": "03-config-md",
"metadata": {},
"source": [
"## 2. Configuration\n",
"\n",
"We use Anthropic Claude Sonnet 4.5 on Bedrock by default, in `us-west-2`. OpenRegistry's hosted endpoint is `https://openregistry.sophymarine.com/mcp` — anonymous tier, no API key required."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "04-config",
"metadata": {},
"outputs": [],
"source": [
"import os\n",
"import nest_asyncio\n",
"\n",
"nest_asyncio.apply()\n",
"\n",
"BEDROCK_REGION = os.environ.get(\"AWS_REGION\", \"us-west-2\")\n",
"BEDROCK_MODEL_ID = \"us.anthropic.claude-sonnet-4-5-20250929-v1:0\"\n",
"\n",
"OPENREGISTRY_MCP_URL = os.environ.get(\n",
" \"OPENREGISTRY_MCP_URL\", \"https://openregistry.sophymarine.com/mcp\"\n",
")\n",
"OPENREGISTRY_TOKEN = os.environ.get(\"OPENREGISTRY_TOKEN\") # optional, only for paid tiers"
]
},
{
"cell_type": "markdown",
"id": "05-llm-md",
"metadata": {},
"source": [
"## 3. Initialize the Bedrock LLM\n",
"\n",
"We use `ChatBedrockConverse` from `langchain-aws` so the model can stream tool calls through LangGraph. The `temperature=0` keeps the agent's tool selection deterministic on repeat runs."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "06-llm",
"metadata": {},
"outputs": [],
"source": [
"from langchain_aws import ChatBedrockConverse\n",
"\n",
"llm = ChatBedrockConverse(\n",
" model=BEDROCK_MODEL_ID,\n",
" region_name=BEDROCK_REGION,\n",
" temperature=0,\n",
" max_tokens=4096,\n",
")\n",
"print(f\"LLM ready: {BEDROCK_MODEL_ID} in {BEDROCK_REGION}\")"
]
},
{
"cell_type": "markdown",
"id": "07-mcp-md",
"metadata": {},
"source": [
"## 4. Connect to the OpenRegistry MCP server\n",
"\n",
"We use `MultiServerMCPClient` from `langchain-mcp-adapters` to attach the remote server. The client opens a Streamable-HTTP session, calls MCP `tools/list`, and converts every advertised tool into a LangChain `BaseTool` instance — so Claude on Bedrock sees ~30 first-class tools at function-calling time."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "08-mcp",
"metadata": {},
"outputs": [],
"source": [
"from langchain_mcp_adapters.client import MultiServerMCPClient\n",
"\n",
"connection = {\n",
" \"url\": OPENREGISTRY_MCP_URL,\n",
" \"transport\": \"streamable_http\",\n",
"}\n",
"if OPENREGISTRY_TOKEN:\n",
" connection[\"headers\"] = {\"Authorization\": f\"Bearer {OPENREGISTRY_TOKEN}\"}\n",
"\n",
"mcp_client = MultiServerMCPClient({\"openregistry\": connection})\n",
"tools = await mcp_client.get_tools()\n",
"print(f\"Discovered {len(tools)} tools from OpenRegistry:\")\n",
"for t in tools[:8]:\n",
" print(f\" - {t.name}\")\n",
"if len(tools) > 8:\n",
" print(f\" ... +{len(tools) - 8} more\")"
]
},
{
"cell_type": "markdown",
"id": "09-agent-md",
"metadata": {},
"source": [
"## 5. Build the LangGraph ReAct agent\n",
"\n",
"The agent has a single role: walk corporate-ownership chains across borders. The system prompt teaches Claude on Bedrock to:\n",
"\n",
"1. Identify the relevant jurisdiction from any company name (`'gb'` for UK, `'de'` for Germany, etc.).\n",
"2. Quote the registry's own `nature_of_control` strings verbatim — never normalise them.\n",
"3. Recurse into corporate PSCs by jumping to their home jurisdiction.\n",
"4. Surface `alternative_url` when an upstream register returns HTTP 501 (CJEU C-37/20 access-gated)."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "10-agent",
"metadata": {},
"outputs": [],
"source": [
"from langgraph.prebuilt import create_react_agent\n",
"\n",
"SYSTEM_PROMPT = (\n",
" \"You are a cross-border KYC / due-diligence assistant with live access to 27 \"\n",
" \"national company registries via the OpenRegistry MCP server. When asked \"\n",
" \"about a company, identify the relevant jurisdiction (ISO 3166-1 alpha-2, \"\n",
" \"e.g. 'gb' for UK, 'de' for Germany, 'fr' for France) and use the OpenRegistry \"\n",
" \"tools to look it up. Always pass jurisdiction='<code>' explicitly. Quote the \"\n",
" \"registry's own field names verbatim — do not normalise PSC `nature_of_control` \"\n",
" \"values. When walking corporate ownership chains across borders, recurse \"\n",
" \"jurisdiction by jurisdiction until you reach an individual or hit an \"\n",
" \"AML-gated register. If a tool returns HTTP 501 with an `alternative_url`, \"\n",
" \"surface that URL to the user — that signals a CJEU C-37/20-restricted \"\n",
" \"register (DE / ES / IT / NL / LU / AT / MT / PT) which only AML-obliged \"\n",
" \"entities can query. Always cite the registry and the company identifier you \"\n",
" \"looked up so the user can verify against the government source.\"\n",
")\n",
"\n",
"agent = create_react_agent(\n",
" llm,\n",
" tools=tools,\n",
" prompt=SYSTEM_PROMPT,\n",
")\n",
"print(\"Agent ready. Tools attached:\", len(tools))"
]
},
{
"cell_type": "markdown",
"id": "11-run-md",
"metadata": {},
"source": [
"## 6. Run the agent — walk Revolut Ltd's PSC chain\n",
"\n",
"We pick **Revolut Ltd** (UK Companies House `08804411`) as the seed entity. The agent should:\n",
"\n",
"1. Search Companies House for the entity (or accept the company number directly).\n",
"2. Call `get_persons_with_significant_control` on it.\n",
"3. For each corporate PSC, jump to its jurisdiction and repeat.\n",
"4. Stop when the chain reaches an individual or hits an AML-gated register.\n",
"5. Cite every hop.\n",
"\n",
"On a typical run you should see 5–10 live MCP tool calls against UK Companies House and any cross-border parents the chain reaches."
]
},
{
"cell_type": "code",
"execution_count": null,
"id": "12-run",
"metadata": {},
"outputs": [],
"source": [
"USER_PROMPT = (\n",
" \"Walk Revolut Ltd's PSC chain (UK Companies House company number 08804411) \"\n",
" \"across jurisdictions until you reach an individual or hit an AML-gated \"\n",
" \"register. Cite the registry and identifier for each hop, and quote the \"\n",
" \"upstream `nature_of_control` strings verbatim.\"\n",
")\n",
"\n",
"result = await agent.ainvoke({\"messages\": [(\"user\", USER_PROMPT)]})\n",
"\n",
"for m in result[\"messages\"]:\n",
" role = m.type if hasattr(m, \"type\") else m.__class__.__name__\n",
" content = m.content if hasattr(m, \"content\") else \"\"\n",
" if hasattr(m, \"tool_calls\") and m.tool_calls:\n",
" for tc in m.tool_calls:\n",
" print(f\" → tool: {tc['name']}({tc['args']})\")\n",
" if content:\n",
" text = content if isinstance(content, str) else str(content)\n",
" print(f\"[{role}] {text[:600]}{'...' if len(text) > 600 else ''}\\n\")"
]
},
{
"cell_type": "markdown",
"id": "13-extra-md",
"metadata": {},
"source": [
"## 7. Try other prompts\n",
"\n",
"Some patterns worth trying with the same agent:\n",
"\n",
"- *\"Find Tesco PLC on UK Companies House (jurisdiction `gb`) and pull the three most recent filings — quote the document_id values verbatim.\"*\n",
"- *\"Pull Samsung Electronics' latest annual disclosure from Korean OpenDART — list the major shareholders disclosed in the most recent filing.\"*\n",
"- *\"Search Spanish BORME for any actos inscritos mentioning Inditex SA in the last 90 days.\"*\n",
"- *\"For an EU/UK Ltd of your choice, walk the corporate-ownership chain across jurisdictions and identify which (if any) layers cross into a CJEU C-37/20 restricted register — call out which AML channel would be needed to continue.\"*\n",
"\n",
"## 8. Production considerations\n",
"\n",
"**Rate limits.** Anonymous tier is 20 req/min/IP with a 3-country fan-out cap. For batch onboarding, complete the OAuth 2.1 flow at [openregistry.sophymarine.com/account](https://openregistry.sophymarine.com/account) and pass the bearer token via the `OPENREGISTRY_TOKEN` env var.\n",
"\n",
"**Provenance.** Every OpenRegistry response keeps the registry's own `company_id` so any fact in the agent's answer can be reconstructed back to the government URL. The Enterprise tier pre-synthesises `source_url` / `registry_url` / `data_license` into every response for one-click audit-trail in compliance reports.\n",
"\n",
"**AML gates.** Eight EU jurisdictions had their UBO registers gated to AML-obliged entities only after CJEU C-37/20 (Nov 2022). The agent handles this honestly — when the upstream returns HTTP 501, the answer surfaces `alternative_url` so the operator knows which AML-obliged channel to use, instead of silently substituting commercial-aggregator data.\n",
"\n",
"## See also\n",
"\n",
"- [OpenRegistry docs / Bedrock integration page](https://openregistry.sophymarine.com/docs/integrations/aws-bedrock)\n",
"- 40 Claude Agent Skills shipping the same workflows: <https://github.com/sophymarine/openregistry/tree/main/skills>\n",
"- Live capability matrix per jurisdiction: call the agent's `list_jurisdictions` tool."
]
}
],
"metadata": {
"kernelspec": {
"display_name": "Python 3",
"language": "python",
"name": "python3"
},
"language_info": {
"codemirror_mode": {
"name": "ipython",
"version": 3
},
"file_extension": ".py",
"mimetype": "text/x-python",
"name": "python",
"nbconvert_exporter": "python",
"pygments_lexer": "ipython3",
"version": "3.11.0"
}
},
"nbformat": 4,
"nbformat_minor": 5
}