
Sanctions Screening Mcp Server
io.github.cyanheadsv0.4.0更新于 Sep 29, 2026
Screen names against OFAC, EU, UK, UN sanctions lists; resolve entities via GLEIF. Screening aid.
安装
在 SourceWeft 中
- 打开 控制台中的 Sanctions Screening Mcp Server,将其添加到工作区。
- 为需要使用其工具的对话启用该服务。
Web executable,通过 Streamable HTTP。 远程服务在工作区中配置后即可从网页运行时运行。
其他 MCP 客户端
把它添加到你客户端的 mcpServers 配置中。
{
"mcpServers": {
"sanctions-screening-mcp-server": {
"type": "http",
"url": "https://sanctions-screening.caseyjhand.com/mcp"
}
}
}README
@cyanheads/sanctions-screening-mcp-server
Screen names against the consolidated OFAC, EU, UK, and UN sanctions lists and resolve legal entities against GLEIF, fuzzy-matched offline over a local SQLite + FTS5 mirror. A screening aid, not a compliance determination.
Public Hosted Server: https://sanctions-screening.caseyjhand.com/mcp
Overview
Sanctions screening and legal-entity resolution over the consolidated OFAC, EU, UK, and UN lists plus the GLEIF LEI registry, matched offline against a local mirror. Screen a name for potential watchlist hits, resolve a company to its LEI, and trace its ownership chain. Runs as a stdio process, a local Streamable HTTP server, or the public hosted endpoint above.
[!IMPORTANT] A screening aid, not a compliance determination. Every result is a potential match with a transparent score and source provenance, never a verdict. A hit is a candidate to verify against the official source; an empty result is never a clearance. Sanctions compliance needs human review and a qualified determination. This server feeds that process and does not perform it.
Tools
Resources
All resource data is also reachable through the tools, for clients that don't surface resources.
Prompts
Capability reference
sanctions_screen_name tool
namein any script (at most 64 words and 1,024 characters) plus optionalsources,entityType, andminScorefilters;matchModeisstrict(default) orfuzzy, and strict falls back to fuzzy when it finds nothing; up to 100 hits per page (limit, default 25) withoffset- Hits carry
source,sourceEntryId, the list's publishedreferenceNumberwhere it has one,matchedNamewith itsmatchedNameType, andmatchType(exact/strong/approximate); approximate hits add the raw Jaro-Winklerscore(0–1) andqueryTokenCoverage totalAvailable,hasMore, andnextOffsetpage the rest;totalAvailableBasismarks the countexactor alower_bound
sanctions_screen_identifier tool
value(an identifier as you hold it) plus optionaltype(anydefault,imo,swift_bic,digital_currency_address,passport,national_id) andsources- Exact match after normalization, never fuzzy and never scored: spacing, letter case, and
-./are ignored; an IMO number matches with or without itsIMOprefix; a SWIFT/BIC compares on its first eight characters, so a branch BIC11 matches its institution's BIC8; a wallet address folds case only for hex (0x…), bech32 (bc1…,ltc1…,bnb1…), and cashaddr encodings, while base58 addresses compare exactly typematches on each list's published label:imois OFACVessel Registration Identification, UKIMO Number, and EUIMO (vessel identification);swift_bicisSWIFT/BICandSWIFT BIC;digital_currency_addressis OFACDigital Currency Address - <code>;passportandnational_idcover the lists' passport and national-ID labels.anyalso reaches every label with no category (MMSI, call signs, tail numbers, tax and registration numbers, email, websites)- One hit per designation, ordered by list then entry ID, each with the
matchedIdentifiersthat matched as published; not paged. An identifier a list prints only in free-text remarks, or bundled with other numbers in one field, does not match
sanctions_get_designation tool
source(ofac_sdn,ofac_consolidated,eu,uk,un) andentryId: thesourceEntryIdfrom a screening hit, or the reference number the list publishes (UNQDe.004, EUEU.27.28, UK OFSI Group ID14196). Matched trimmed and case-insensitive, entry ID first; a reference number two designations share fails asreference_ambiguous, naming both- Returns
referenceNumberbesidesourceEntryIdwhere the list publishes one; OFAC publishes none, its entry ID being its published number - All published aliases, identifiers, addresses, dates and places of birth, nationalities, program, legal basis, and designation date; a field the source omitted is absent, never filled in
- Identifiers cover identity documents plus the SWIFT/BIC codes, digital-currency addresses, vessel call signs, aircraft tail and serial numbers, phone numbers, email addresses, and websites a list publishes, each typed with the list's own label and its value verbatim
- Dates of birth are ISO 8601 at the precision the source published (
1952-10-07,1946-08,1938, or an interval such as1955/1957), withcirca: truewhere the source marks one approximate;designationDateis the source's own designation date asYYYY-MM-DD
sanctions_resolve_entity tool
namein any script (same bound assanctions_screen_name) plus optionaljurisdiction,status, andminScore; samematchModebehavior assanctions_screen_name; up to 50 candidates per page (limit, default 10) withoffsetjurisdictionis a country code, which matches the country and every subdivision under it (USmatchesUS-DEandUS-CA), or an ISO 3166-2 subdivision code (US-DE), matched exactly; case-insensitivestatus:issued(default) matchesISSUED,lapsedmatches exactlyLAPSED, andanyapplies no filter — the only way to reachRETIRED,DUPLICATE,ANNULLED,PENDING_TRANSFER,PENDING_ARCHIVAL, orMERGEDrecords, each candidate'sstatusnaming its state- Searches every name GLEIF publishes: the legal name, previous legal names, trading names, alternative-language legal names, and ASCII transliterations of a legal name in another script
- Candidates carry
lei,legalName, thematchedNameand itsmatchedNameType(LEGAL_NAME,PREVIOUS_LEGAL_NAME,TRADING_OR_OPERATING_NAME, …, orUNKNOWNfor a name stored without a type), andmatchType, withscoreandqueryTokenCoverageon approximate matches; one candidate per LEI, paged by the sametotalAvailable/totalAvailableBasis/hasMore/nextOffsetfields
sanctions_get_entity tool
- One 20-character
lei; returns the legal name,otherNames, legal and headquarters addresses,status,jurisdiction, registration authority, andlastUpdate alternateNameslists every other and transliterated name with its GLEIF type; a name the mirror stored before types were kept reads asUNKNOWNsanctionsHitsscreens the legal name against every watchlist, strict-only and capped at 25;screeningStatus(screened/not_ready) says whether that screen ran, andsanctionsScreen.hasMoreflags a capped list
sanctions_trace_ownership tool
- Root
lei,direction(parents/children/both, defaultboth), anddepth1–5 (default 3);screenNodes: truescreens every node's legal name, strict-only, up to 10 hits per node nodes(withroleanddepth) andedges(withrelationshipType);completeis false when the graph istruncatedat the depth limit ormissingEntityLeislists nodes with no Level 1 record. It covers the loaded relationships only, and most entities publish no parent relationship.- Each node whose parents the walk read carries
parentStatus.direct/.ultimate:relationship,exception(a GLEIF reporting exception, with every reason, such asNATURAL_PERSONS),none, orunknown.unknownmeans exception data is not loaded, whichreportingExceptionsLoaded: falsestates. screeningStatus(screened/not_requested/not_ready),screenedNodeCount, andflaggedNodeCount; each screened node reports its ownsanctionsScreen.hasMore
sanctions_list_sources tool
- No input; one row per sanctions list plus
gleif, each withrecordCount,url, andlicense sanctionsReady/sanctionsAsOfandleiReady/leiAsOfreport whether each mirror has synced and when;sanctionsAsOfis the last sync in which every sanctions list refreshed. Not gated on readiness, so it reports an empty mirror instead of failingreportingExceptionsLoaded, and on thegleifrow areportingExceptionCountonce the reporting exceptions are loaded
sanctions://designation/{source}/{entryId} resource
sourceis one of the five list codes,entryIdthe list's own ID or its published reference number, resolved assanctions_get_designationresolves it and decoded once when percent-encoded; returns thesanctions_get_designationpayload asapplication/json- Cached for an hour, scoped
private
sanctions://entity/{lei} resource
- The
sanctions_get_entityLevel 1 payload for onelei, without the sanctions cross-reference, which is tool-only - Cached for an hour, scoped
private
sanctions://sources resource
- The
sanctions_list_sourcespayload, plus the GLEIFrelationshipCount ttlMs: 0: never cached, because readiness and the as-of timestamps are the payload
sanctions_vet_counterparty prompt
- Arguments:
namerequired;jurisdictionoptional (a country code, which includes its subdivisions, or an ISO 3166-2 subdivision code) - Returns one user message that screens the name (and any identifier the caller holds, with
sanctions_screen_identifier), resolves it to an LEI, traces ownership withscreenNodes: true, pulls each hit's designation record, and asks for a summary that treats every match as a candidate to verify. It names GLEIF parents as accounting-consolidation parents, not beneficial owners, and reports a reporting exception's reasons rather than reading it as "no parent"
Features
Built on @cyanheads/mcp-ts-core: stdio and Streamable HTTP transports, pluggable auth (none / jwt / oauth), swappable storage (in-memory, filesystem, Supabase, Cloudflare KV/R2/D1), structured logging with optional OpenTelemetry tracing.
Sanctions-screening-specific:
- One screen covers OFAC SDN, OFAC Consolidated, EU, UK (UKSL), and UN; the matching list shows up as per-hit provenance
- Offline and keyless, with no per-request rate limit: all five sources are normalized into local SQLite + FTS5 mirrors via the framework
MirrorService - Per-alias name index, so a query matches any of an entity's names in one FTS scan, and a per-identifier index for exact lookup by IMO number, SWIFT/BIC, wallet address, or document number
- Strict-then-fuzzy matching: exact-normalized, then all tokens present (FTS5), then Jaro-Winkler + Double-Metaphone, with fuzzy candidates capped by
SANCTIONS_FUZZY_MAX_RESULTS - GLEIF Level 1 (who is who) and Level 2 (who owns whom) for entity resolution and ownership tracing
Agent-friendly output:
- Real signal with provenance: approximate hits carry the raw Jaro-Winkler
scoreand a literalqueryTokenCoveragecount, never a blended confidence; every hit names its list, program, designation date, and the name that matched, typedprimary/aka/fka/low-quality-aka - Decision-support
caveatin the output ofsanctions_screen_name,sanctions_screen_identifier,sanctions_get_designation,sanctions_get_entity, andsanctions_trace_ownership - Disclosed gaps:
totalAvailableBasis,screeningStatus,complete/truncated/missingEntityLeis, and each node'sparentStatussay what a response did not cover - Typed errors: every tool and resource except the sources listing fails as
mirror_not_ready(retryable) until its mirror is loaded; unknown IDs fail asdesignation_not_foundorlei_not_found, and a reference number two designations share asreference_ambiguous; a name with no letter or digit fails asname_not_searchable, one past the length bound asname_too_long, and an identifier with nothing left once spacing and separators are removed asidentifier_not_searchable
Getting started
Public Hosted Instance
A public instance is available at https://sanctions-screening.caseyjhand.com/mcp — no installation required. Point any MCP client at it via Streamable HTTP:
Self-Hosted / Local
Add the following to your MCP client configuration file. The mirror is not bundled: populate it with bun run mirror:init before screening (see Mirror lifecycle).
Or with npx (no Bun required):
For Streamable HTTP, set the transport and start the server:
Prerequisites
- Bun v1.4.0 or higher (or Node.js v24+).
- Disk for the local mirror; GLEIF Level 1 takes most of it. No source needs an API key.
Installation
- Clone the repository:
- Navigate into the directory:
- Install dependencies:
- Configure environment:
- Populate the mirror:
Configuration
Every source is keyless, so nothing here is required.
See .env.example for the full list of optional overrides.
Running the server
Local development
-
Build and run:
-
Run checks and tests:
Mirror lifecycle
The mirror loads out-of-band, never on the request path.
A list that fails to download or arrives truncated keeps its stored rows and removes nothing, while every other list still refreshes and the name and identifier indexes are rebuilt. GLEIF still refreshes after it, and the run then exits non-zero naming each failed list (ofac_sdn, eu, …). sanctionsAsOf advances only on a run in which every list refreshed, and a first mirror:init with a failed list leaves the mirror not ready.
GLEIF advances all at once or not at all. leiAsOf and the checkpoint move only after every dataset's covering delta has applied, so an interrupted refresh leaves both where they were, and the next run re-applies from the same point. Two cases apply nothing to GLEIF, leave leiAsOf unchanged, and exit non-zero naming mirror:init: a checkpoint older than the one-month window, and a mirror with no checkpoint. Every mirror written by 0.3.0 or earlier has no checkpoint, so run mirror:init once after upgrading. Until then, traces read unpublished parents as unknown, and sanctions_resolve_entity searches legal names only, with a notice saying alternate names are not yet indexed: 0.3.0 stored other names without their types and no transliterated names at all, so the name index is built from the golden copy mirror:init loads, never from the stored rows. Every GLEIF write after that keeps the index current. A GLEIF write by an earlier release, after a rollback, leaves it behind, and resolution returns to legal names and the notice until the next mirror:init.
A mirror written by an earlier release upgrades in place on first open: the designation table gains its reference_number column, and the identifier index is built from the stored records before the first lookup, so sanctions_screen_identifier answers from a populated mirror without a re-init. What the earlier release did not read — reference numbers, the OFAC and UK identifiers beyond identity documents, and dates at published precision — arrives with the next sanctions refresh, so run mirror:refresh after upgrading rather than waiting for the cron. The index is also rebuilt on open whenever a sync it did not follow has changed the stored records, such as one an earlier release ran after a rollback.
Every leg of mirror:init and mirror:refresh streams in bounded batches, so peak memory tracks the batch size, not the source size. The sanctions XML totals about 172 MB. The GLEIF Level 1 golden copy is about 3.4M records (~890 MB compressed) and dominates disk use; its name index (~4M names) adds about 0.8 GB of that. The reporting exceptions add about 6.4M rows (~525 MiB on disk).
Docker
The Dockerfile defaults to HTTP transport, stateless session mode, and logs to /var/log/sanctions-screening-mcp-server. Mount a volume at /usr/src/app/data so the mirror survives restarts, and populate it with bun run mirror:init via docker exec. OpenTelemetry peer dependencies are installed by default — build with --build-arg OTEL_ENABLED=false to omit them.
Project structure
Development guide
See CLAUDE.md/AGENTS.md for development guidelines and architectural rules. The short version:
- Handlers throw, framework catches — no
try/catchin tool logic - Use
ctx.logfor request-scoped logging; the mirror is reached throughgetScreeningService(), notctx.state - Register new tools and resources via the barrels in
src/mcp-server/*/definitions/index.ts - Wrap external sources: validate raw → normalize to the common schema → return the output schema; never fabricate fields a source omits, and never synthesize a confidence score
Attribution
This server redistributes open data from these sources, cited per their terms:
UKSL has been the single authoritative UK source since the OFSI Consolidated List closed on 28 January 2026.
Contributing
Issues are welcome. Run checks and tests before submitting:
License
Apache-2.0 — see LICENSE for details.
来源:README.md,提交 e4a00d7
工具
0版本历史
5- v0.4.0最新Sep 25, 2026
- v0.3.0Sep 25, 2026
- v0.2.0Sep 25, 2026
- v0.1.12Sep 21, 2026
- v0.1.11Sep 16, 2026
