Skip to content

MCP tools

Every tool arche mcp registers: its arguments with their types, what it returns, and what it never returns. At the end of this page you can read an agent's transcript and know what each call could and could not have told it. Let an agent call arche is the walkthrough; this page is the contract.

Every example below calls the tool's handler directly in Python, which is the same function the server registers, minus the environment. The handlers take the environment's values as arguments (key, statute, allowed_providers, transfer_basis, ledger), so a page can show a refusal without setting a variable.

from arche.mcp import handlers

caps = handlers.capabilities()
print(caps["entity_packs"])
print(caps["jurisdictions_inferable"])
print(sorted(caps["extras"]))
['artist', 'organisation', 'organization', 'person', 'place', 'product_electronics', 'product_grocery', 'product_home_goods']
['DE', 'EU', 'GB', 'KE', 'NG', 'US', 'ZA']
['detect', 'doc', 'splink']
Tool Arguments Returns Never returns
capabilities statutes, inferable jurisdictions, entity packs, installed extras, signer, ledger tools
infer_jurisdiction text country, confidence, margin, abstained, evidence, statute_id, policy_available the matched text behind a signal
plan_protection jurisdiction?, statute? verdict, governed, covered, uncovered, degraded_categories
describe_pack entity the fields a pack reads, each with kinds, weight and notes
detect_pii text, jurisdiction? spans as offsets, category, confidence, citation, tier; coverage the value of a span
detect_entities text, entity_types? typed offset spans; ner_backend_installed the entity text
guarded_scan text, jurisdiction?, provider?, crosses_border? denied with a reason, or redacted_text, fields with hashed tokens, coverage a raw value; offsets into the original
compare_records list_a, list_b, entity?, comparators?, backend?, threshold?, id_field? matches with ids, score, decision, evidence, decision_id; pins, blocking, backend record values
why_unresolved record_a, record_b, entity? decision, why, fields_present, would_resolve, will_not_help a promise of no_match
check_name_equivalence name_a, name_b decision, score, bands
extract_places text roles, offsets, cues, confidence, component names; pack the address text or components
decision decision_id the receipt, its pins, the two records by label and field names, entity_id record values
explain decision_id supporting, refuting, missing, shared (names) values
replay decision_id reproduced, then, now, changed values
entities entity_type? entities with records by label, shared and conflicting field names, held_together_by, weak_links values
path record_a, record_b same_entity, hops, the decisions between, note values
cases entity_type? pairs at review with would_resolve values
resolve record, entity_type verdict, the entity, candidates, entity_evidence, would_resolve, new decisions values out of the ledger
observe record_id, evidence field names, the superseding decisions, open_cases the evidence back

The first eleven are always registered. The last eight appear only when ARCHE_LEDGER is set, so a client of an unconfigured server never sees a tool that can only fail. No tool has a reveal option, touches the filesystem, or keeps state between calls; the ledger file is the operator's, and the agent gets ids, labels, field names and numbers out of it.

Environment

Variable What it does
ARCHE_JURISDICTION ceiling jurisdiction; a call may not override it
ARCHE_STATUTE ceiling statute id; a call may not override it
ARCHE_HASH_KEY required for guarded_scan; no key, no tokens
ARCHE_ALLOWED_PROVIDERS comma-separated model-provider allow-list
ARCHE_TRANSFER_BASIS declared cross-border transfer basis
ARCHE_LEDGER duckdb:///FILE (or a path): record decisions there and register the eight ledger tools
ARCHE_SIGNING_KEY PEM from arche attest keygen: every answer then carries a signed attestation

The ceiling rule: ARCHE_JURISDICTION and ARCHE_STATUTE, when set, are the policy every call runs under, and a jurisdiction argument is ignored in their favour. Unset, the argument decides, which is what a mixed document stream needs: infer_jurisdiction, then plan_protection, then guarded_scan. An agent that can choose its statute can choose a weaker one, so pin the ceiling when a deployment handles one jurisdiction.

With ARCHE_SIGNING_KEY, every tool's answer carries one more key, attestation: a JWS over the tool name, a hash of the arguments, a hash of the answer and the decision ids in it. stdio has no caller identity, so caller is null. Attestation says what it proves.

capabilities

No arguments. Returns arche_core_version, statutes_available, jurisdictions_with_a_statute, jurisdictions_inferable, entity_packs, extras (detect, splink, doc, each true only when the module actually imports), attestation ({"signer": "did:key:..."} or null) and ledger (configured, and the tools it added). Call it first: several tools return empty results rather than errors when an extra is missing, and this is how to tell the two apart. The example at the top of the page is its output.

infer_jurisdiction

Argument Type
text str
note = "Patient Casey Example (NIN 12345678901) called from 0803 555 7890."

j = handlers.infer_jurisdiction(note)
print(j["country"], j["confidence"], j["abstained"], j["statute_id"], j["policy_available"])
print(j["evidence"])
NG 1.0 False NDPA-2023 True
[{'signal': 'id.nin', 'tier': 'A', 'country': 'NG', 'count': 1, 'weight': 1.0, 'source': 'text'}]

Also margin, runner_up, reason, ruleset_version, policy_reason and policy_alternatives. abstained: true means nothing in the text said where it came from. policy_available: false means a country was inferred and no statute pack covers it (the United States, for one); policy_alternatives names what can be passed explicitly. Evidence carries the signal name and count, never the matched text, because a sample can be a registration identifier.

plan_protection

Argument Type Default
jurisdiction str \| None None, or the ceiling
statute str \| None None, or the ceiling
plan = handlers.plan_protection(jurisdiction="NG")
print(plan["verdict"], plan["statute_id"], len(plan["governed"]), len(plan["covered"]))
print(plan["uncovered"][:4], plan["degraded_categories"])
partial NDPA-2023 27 14
['PII-5-BANK_ACCOUNT', 'PII-5-CARD', 'PII-5-MPESA_REF', 'PII-6-BIOMETRIC'] []

verdict is full, partial, none or no-statute. partial is the normal answer and not a reason to stop on its own; degraded_categories (a detector exists and was built for somewhere else) is the sharper signal, and none means a clean result would mean nothing was looked for. Also detector_packages, detectable_categories, calibration_mismatch and note.

describe_pack

Argument Type
entity one of the entity_packs from capabilities
pack = handlers.describe_pack("place")
print(pack["field_names"], pack["ignores_everything_else"])
print([(f["field"], f["kinds"], f["weight"]) for f in pack["fields"]])
['address', 'admin_path', 'lat', 'lon', 'name'] True
[('name', ['placename', 'tftoken', 'type'], 4.0), ('address', ['address'], 1.0), ('admin_path', ['containment'], 1.0), ('lat + lon', ['geo'], 1.0)]

Each field also carries notes (what each kind does, in words), refutes, and for coordinates geo with decay_km and veto_km. A field the pack does not name is ignored, not rejected: no error, no effect on the score. Call this before compare_records to map your columns.

detect_pii

Argument Type Default
text str
jurisdiction str \| None None, or the ceiling
d = handlers.detect_pii(note, jurisdiction="NG")
print(d["count"], d["coverage"]["verdict"])
for span in d["detections"]:
    print(span)
2 partial
{'category': 'PII-2-NIN', 'start': 27, 'end': 38, 'confidence': 0.6, 'citation': 'NDPA-2023 s.30, NIMC Act s.27', 'tier': 'high'}
{'category': 'PII-3-PHONE', 'start': 52, 'end': 65, 'confidence': 0.9, 'citation': 'NDPA-2023 s.30', 'tier': 'moderate'}

Offsets index the original text; the caller holds it and slices. coverage is the same block plan_protection returns, because count: 0 on its own does not say whether the document is clean or nothing installed could read it.

detect_entities

Argument Type Default
text str
entity_types list[str] \| None None
e = handlers.detect_entities("Adesola Okonkwo of Kijani Tea Exporters Ltd, Nairobi")
print(e["count"], e["ner_backend_installed"])
print(e["entities"])
1 True
[{'type': 'PERSON', 'start': 0, 'end': 15, 'confidence': 0.7, 'source': 'lexicon'}]

Without the detect extra this finds pattern-shaped identifiers and lexicon names and no model-proposed people, organisations or places; ner_backend_installed says which it was, so check it before reading an empty result as clean. source names where each span came from.

guarded_scan

Argument Type Default
text str
jurisdiction str \| None None, or the ceiling
provider str \| None None
crosses_border bool False

The handler also takes key, statute, allowed_providers and transfer_basis, which the server fills from the environment.

g = handlers.guarded_scan(note, key="", jurisdiction="NG")
print(g["denied"], "|", g["reason"])

g = handlers.guarded_scan(note, key="demo-key", jurisdiction="NG")
print(g["denied"], g["offsets_match_original"], g["coverage"]["verdict"])
print(g["redacted_text"])
print([(f["category"], f["action"], f["tier"], f["token"][:12]) for f in g["fields"]])

g = handlers.guarded_scan(note, key="demo-key", jurisdiction="NG",
                          provider="openai", allowed_providers=["anthropic"])
print(g["denied"], "|", g["reason"])
True | no hash key configured, so tokens could not be stable and the guard refuses rather than inventing one (set ARCHE_HASH_KEY)
False False partial
Patient Casey Example (NIN [NIN:de11776532b6f1b0]) called from [PHONE:53bb10448ce546a9].
[('PII-2-NIN', 'mask', 'high', 'de11776532b6'), ('PII-3-PHONE', 'tokenize', 'moderate', '53bb10448ce5')]
True | provider 'openai' is not in the allow-list ['anthropic']

Five refusals, all deny by default: no hash key, no statute for the jurisdiction, a provider outside the allow-list, a cross-border transfer with no declared basis, and no installed detector for anything the statute governs. A refusal is {"denied": true, "reason", "citation", "coverage"?}. An answer carries redacted_text, fields (each category, action, token, citation, tier), metadata, coverage and offsets_match_original: false: the tokens are a different length from what they replaced, so offsets from detect_pii do not index redacted_text. The same value gives the same token across calls under the same key, which is how an entity is correlated across documents without the server storing anything.

compare_records

Argument Type Default
list_a, list_b list[dict]
entity one of the entity_packs, or None None
comparators list[dict] \| None None
backend str \| None None (arche's own scorer); "splink" derives a Splink configuration from the pack
threshold float 0.7
id_field str "id"

Pass entity or comparators, not both and not neither. The handler also takes ledger, which the server fills from ARCHE_LEDGER; with one, every edge is recorded.

import arche

ledger = arche.attach("duckdb:///:memory:")
suppliers = [{"id": "s1", "name": "Kijani Tea Exporters Ltd", "city": "Nairobi"},
             {"id": "s2", "name": "Central Cooperative Society", "city": "Nakuru"}]
registry = [{"id": "r1", "name": "Kijani Tea Exporters Limited", "city": "Nairobi"},
            {"id": "r2", "name": "Central Cooperative Society", "city": "Nakuru"}]

res = handlers.compare_records(suppliers, registry, entity="organisation", ledger=ledger)
for m in res["matches"]:
    print(m["a_id"], m["b_id"], m["decision"], m["score"], m["distinctive_max"], m["decision_id"][:24])
print(res["count"], res["backend"]["chosen"], sorted(res["pins"]))
s1 r1 match 1.0 0.86 xwd:sha256:a96f6fd332e6d
s2 r2 review 1.0 0.594 xwd:sha256:81709217510d8
2 arche ['block', 'comparators_sha256', 'distinctive_floor', 'engine', 'review_margin', 'tf', 'threshold']

Each edge is a_id, b_id, score, decision (match or review), evidence (one number per field), distinctive_max and decision_id. review is a real answer: the records agree and nothing they agree on is distinctive enough to assert a match, which is the second pair here. Pairs below the review floor are not returned. pins records which engine and which frequency table decided; blocking says how many candidate pairs were scored.

why_unresolved

Argument Type Default
record_a, record_b dict
entity one of the entity_packs "place"
w = handlers.why_unresolved({"name": "Central Cooperative Society", "city": "Nakuru"},
                            {"name": "Central Cooperative Society", "city": "Nakuru"},
                            entity="organisation")
print(w["decision"], "|", w["why"])
print([(f["field"], f["effect"]) for f in w["would_resolve"]])
print([f["field"] for f in w["will_not_help"]])
review | the records agree, and what they agree on is ordinary in this pack's reference population (distinctive_max 0.594 < floor 0.75). Agreement on a common token is not evidence of identity
[('registration_id', 'decisive_for'), ('entity_class', 'can_prevent_match'), ('address', 'independent_signal'), ('lat + lon', 'independent_signal')]
['name']

Effects, strongest first: hard_constraint (can cap the decision at review on its own), decisive_for (an exact identifier; agreement settles it), can_prevent_match (disagreement holds it), independent_signal (adds evidence without deciding alone). Read will_not_help before retrying: a cleaner rendering of a field that already agrees cannot raise its rarity. When the pair was never surfaced as a candidate, decision is not_surfaced and the lists are empty. Nothing here promises no_match.

check_name_equivalence

Argument Type
name_a, name_b str
print(handlers.check_name_equivalence("Fatima Abdullahi", "Fatuma Abdulahi"))
print(handlers.check_name_equivalence("Fatima Abdullahi", "Grace Mwangi"))
{'decision': 'match', 'score': 0.9053, 'bands': {'match': 0.85, 'review': 0.7}}
{'decision': 'no_match', 'score': 0.5097, 'bands': {'match': 0.85, 'review': 0.7}}

decision is match at or above 0.85, review at or above 0.7, no_match below. A band rather than a boolean, so "close and not close enough" has a name.

extract_places

Argument Type
text str
p = handlers.extract_places("Send my package from 123 Maple Street to the blue gate behind Elim Pharmacy, 124 Elim Street.")
print(p["count"], p["offsets_match_original"], p["pack"])
for m in p["places"]:
    print(m["role"], (m["start"], m["end"]), m["cue"], m["cue_rule"], m["confidence"], m["components_present"])
2 True arche.place_roles@v1:sha256:52ff7520cad60a44
origin (21, 37) from from_origin 0.95 ['street', 'street_number']
destination (55, 92) to to_destination 0.7 ['anchor', 'anchor_type', 'street', 'street_number']

role is origin, destination, location, via or unknown; absent or conflicting cues come back unknown at floor confidence rather than guessed. Each mention also carries cue_start, cue_end, evidence, jurisdiction, jurisdiction_confidence and has_address. An address is personal data, so the address text and its components never come back, only their names; the HTTP route of the same name returns them, because there the caller sent the text.

decision

Registered with ARCHE_LEDGER. The handler takes the ledger as its first argument.

Argument Type
decision_id str
did = res["matches"][0]["decision_id"]
dec = handlers.ledger_decision(ledger, did)
print(dec["verb"], dec["identity"], dec["action"], dec["score"])
print([(r["label"], r["fields"], r["from_text"]) for r in dec["records"]])
print(dec["entity_id"].startswith("ent_"), dec["supersedes"], dec["superseded_by"])
reconcile match merge 1.0
[('s1', ['city', 'id', 'name'], False), ('r1', ['city', 'id', 'name'], False)]
True None None

Also decision_id, explanation, factors, pins, record_a, record_b (content addresses of the form rec:sha256:), recorded_at. records gives each record's record_id, label (the caller's id, or the address), entity_type and the names of its fields.

explain

Argument Type
decision_id str
why = handlers.ledger_explain(ledger, did)
print(why["supporting"], why["refuting"], why["missing"], why["shared"])
['name', 'name_tftoken', 'name_type'] [] ['national_id', 'registration_id', 'phone', 'email', 'dob', 'address'] []

shared is the names of the fields both records carry with the same value, sorted; the CLI and HTTP forms can show the values with reveal, this one cannot. Also identity, action, basis, explanation, gate and decision_id.

replay

Argument Type
decision_id str
rp = handlers.ledger_replay(ledger, did)
print(rp["reproduced"], rp["changed"])
print(rp["then"])
print(rp["now"]["decision_id"] == did)
True {}
{'identity': 'match', 'action': 'merge', 'score': 1.0, 'engine': 'crosswalk.v1'}
True

then and now each carry identity, action, score and engine; now adds the new decision_id. reproduced is true when it is the same id byte for byte; otherwise changed names every factor and pin that moved.

entities

Argument Type Default
entity_type str \| None None
ents = handlers.ledger_entities(ledger)
print(ents["count"])
for ent in ents["entities"]:
    print([r["label"] for r in ent["records"]], ent["held_together_by"],
          ent["shared_fields"], ent["conflicting_fields"], ent["weak_links"], ent["decisions"])
1
['s1', 'r1'] direct ['city'] ['name'] [] 1

Largest entity first. Each has entity_id, entity_type, records (by record_id and label), shared_fields and conflicting_fields (names only: Ltd and Limited are a conflict on name even though the pack matched them), held_together_by (direct or transitive), weak_links (the records that hold a transitive entity together), bridges and the number of decisions. The pair at review is not an entity: review never merges.

path

Argument Type
record_a, record_b str, the record_id from decision, entities or cases
chain = handlers.ledger_path(ledger, dec["record_a"], dec["record_b"])
print(chain["same_entity"], chain["hops"], "|", chain["note"])
print([(s["from"], s["to"], s["identity"]) for s in chain["decisions"]])
True 1 | one decision: the two were compared and matched directly
[('s1', 'r1', 'match')]

Each step is a decision with from and to labels. More than one hop means the two were never compared and the records between them were; an empty chain means they are not one entity. The argument is the rec:sha256: address, not the caller's label.

cases

Argument Type Default
entity_type str \| None None
cases = handlers.ledger_cases(ledger)
print(cases["count"])
case = cases["cases"][0]
print(case["a"]["label"], case["b"]["label"], case["score"], "|", case["explanation"])
print(case["supporting"], case["refuting"], case["would_resolve"])
1
s2 r2 1.0 | review: agrees on name, name_tftoken, name_type
['name', 'name_tftoken', 'name_type'] [] ['registration_id', 'entity_class', 'address', 'lat + lon']

Each case carries its decision_id, a and b (record_id and label), score, explanation, supporting, refuting and would_resolve: the fields that would settle it. Fetch one of those from wherever you can and hand it to observe.

resolve

Argument Type
record dict, non-empty
entity_type one of the entity_packs
r = handlers.ledger_resolve(ledger, {"name": "Kijani Tea Exporters", "city": "Nairobi"},
                            entity_type="organisation")
print(r["verdict"], "|", r["note"])
print([x["label"] for x in r["entity"]["records"]])
print([(v["identity"], v["matched"], v["members"]) for v in r["candidates"].values()])
print(r["entity_evidence"]["identity"], r["conflicting_fields"], r["would_resolve"])
found | matched 2 member(s) of one entity
['s1', 'r1', '0']
[('match', 2, 2)]
review [] []

The record is compared with every stored record of its type, grouped by entity, and decided at the entity level: found (linked, and now a member), review (the nearest pairs opened as cases), ambiguous (members of two entities matched; link withheld), conflict (contradicts an identifier the entity shares; withheld), not_found (stored on its own). candidates is keyed by entity id with identity, score, matched, review and members; entity_evidence compares the record with the entity's merged profile; decisions lists every new receipt. A record without an id is labelled by its position, 0 here.

observe

Argument Type
record_id str, the record_id from cases or decision
evidence dict of field: value, non-empty
first = handlers.ledger_observe(ledger, case["a"]["record_id"], {"registration_id": "CS-1188"})
print(first["fields"], [(d["identity"], d["action"], d["supersedes"] == case["decision_id"]) for d in first["decisions"]], first["open_cases"])

second = handlers.ledger_observe(ledger, case["b"]["record_id"], {"registration_id": "CS-1188"})
print(second["fields"], [(d["identity"], d["action"], d["factors"]) for d in second["decisions"]], second["open_cases"])
print(handlers.ledger_cases(ledger)["count"])
['registration_id'] [('review', 'hold', True)] 1
['registration_id'] [('same_entity', 'merge', {'name': 1.0, 'name_tftoken': 1.0, 'name_type': 1.0, 'registration_id': 1.0})] 0
0

The values go into the operator's ledger file; the reply carries fields (names), the new decisions (each naming what it supersedes; nothing is overwritten) and open_cases. One side carrying a registration id is still review, because the other side has nothing to agree with; once both carry it the pair is same_entity and the queue is empty.

You want to Read
run the server, see the tools from a script, the Inspector and a chat client Let an agent call arche
the same eight ledger operations from Python Keep, explain, replay
the same verbs over plain HTTP HTTP API
what the signed attestation proves Attestation
the server behind an auth proxy Docker and deploy