Tool profiles and input policy
Show how a corpus profile narrows the tool surface (facets), what a caller sees when invoking a forbidden or unknown tool, which inputs carry rights and which do not, what side effects tools have, and where fact authorship is and is not verified.
Component law-mcp-server (law-mcp/): tool
registry data/tools.json (36 tools) plus science tools
(src/science.rs), facet gating (src/server.rs, src/profile.rs),
fact schema (law_ask.facts, law_case_*). Scenario: operating one
profiled slice (e.g. islam on /mcp/islam). All calls below are
synthetic created-examples; tool names, facet names, error codes, and
schema fields are real.
Prerequisites
Section titled “Prerequisites”- A profile file
<name>.jsonin the checkout’s profiles directory, orall. - A running instance started with
--profile <name>(facets bind at startup; changing the file needs a restart). - A token or loopback access (see Authentication and access control).
Facets: the nine tool groups
Section titled “Facets: the nine tool groups”Every tool carries one facet. A profile’s facets.allow lists the
served facets; facets.deny maps each withheld facet to a non-empty
human reason. Validation (validate_facets) is strict: each of the
9 known facets must be named exactly once — in allow or deny,
never both, never neither; duplicates and empty reasons refuse
startup. Profile all allows all 9 with no reasons.
| Facet | Tools (from tools.json, contract order) | Typical effect |
|---|---|---|
reference | law_search, law_passport, law_answer_capabilities, law_packages, law_rules, law_sources, law_query | read-only: find and quote formalized law |
execution | law_capture_answer, law_prepare_answer, law_publish_answer, law_revoke_answer, law_ask, law_screen, law_explain, law_inspect, law_case_ask | evaluate; prepare/publish/revoke frozen answer documents |
drafting | law_check, law_pin, law_draft_check, law_draft_eval, law_measure, law_draft_verbalize, law_draft_differential, law_draft_impact | author workbench: check drafts, pin sources, measure completeness |
editing | law_case_propose, law_case_facts, law_case_decide, law_case_document, law_case_check | case-package journal: propose/accept facts, write documents |
argumentation | law_argue | read-only: dispute map over a derivation |
adversarial | law_loophole, law_amend | circumvention analysis; amendment drafting |
process | law_process, law_process_run | read-only: procedure graph; replay a case journal |
editions | law_editions | read-only: what changed between two law-dates |
feedback | law_report | append a formalization-divergence report to the server journal |
Real example — the shipped islam profile allows 6 facets and denies
3 with reasons (drafting: “author workbench … is not executed on the
public slice”; adversarial: doctrine “is not amended”; editing:
“executed only on the author’s local slice”). Copy that shape for a
new slice; the reason strings are operator-chosen text.
Steps: serve a narrowed slice
Section titled “Steps: serve a narrowed slice”- Write the profile:
allowthe facets the slice needs,denythe rest with a reason each (all 9 named exactly once). - Start with
--profile <name>and read the stderr launch line for the profile name. - Call
tools/list(real method, synthetic transport): the reply contains only allowed-facet tools, with thefacetfield stripped. Count them against the table above. - Call one denied tool directly (next section) and confirm
-32002.
Direct call of a forbidden tool
Section titled “Direct call of a forbidden tool”tools/list filtering is presentation; enforcement happens per call
in call_tool. A direct tools/call naming a denied-facet tool
returns a JSON-RPC error (real codes, synthetic ids):
{"jsonrpc": "2.0", "id": 7, "error": {"code": -32002, "message": "tool 'law_pin' ... facet 'drafting' ... <deny reason>"}}Error-code table (all verified in server.rs):
| Code | Meaning | When |
|---|---|---|
-32002 | facet denied | tool exists but its facet is not in facets_allow; message quotes the profile’s deny reason |
-32602 | unknown tool | name matches no registry entry |
-32601 | unsupported method | method is not initialize/tools/list/tools/call/prompts/.../ping/notifications |
-32001 + CALL_TIMEOUT | call overran LAW_MCP_CALL_TIMEOUT | computation abandoned; result discarded; overrunCalls in /healthz counts it |
-32603 + INTERNAL | handler panicked | never a legal outcome; journal records MCP_TRACEBACK |
Input-predicate rights: none per predicate
Section titled “Input-predicate rights: none per predicate”law_ask (and law_argue, law_process_run, law_case_ask) accept
facts shaped {predicate, args, negated?, package?, provenance?, judgment?}. There is no per-predicate allow-list, no per-fact
role, and no caller-rights check on inputs: any caller passing the
instance check (see Authentication)
may assert any predicate with any arguments. What the server does instead:
packageon a fact disambiguates a short predicate name; it grants nothing.- Unknown predicates and unsatisfied premises surface as
“not established” with a
whyNotreport — a logical outcome, not a refusal. law_screenbuilds only a superset pre-filter: every passing row must still be fully evaluated with a proof graph; the screen grants no shortcut around evaluation.
Fact actions are call-scoped: facts live for one evaluation and are
never stored, except inside the call journal record (capped by
LAW_MCP_LOG_MAX_BODY) and inside caller-built case packages via
the editing facet.
Effects: the authoritative matrix
Section titled “Effects: the authoritative matrix”This table is the single source of truth for what each tool class reads, writes, sends out, and requires for affinity. Other articles defer to it; if they seem to disagree, this table wins and the other article is a bug — report it.
| Effect class | Tools | Trigger / setting | Reads / writes / sends | Same instance? | Re-creatable after loss? |
|---|---|---|---|---|---|
| Pure evaluation | law_ask, law_argue, law_explain, law_inspect, law_editions, law_process, law_process_run, law_query, law_screen, reference readers | facet allowed | reads corpus + case input; writes nothing (journal/OTLP observe, never alter answers) | no — any identical instance | n/a (no state) |
| Answer publication | law_capture_answer, law_prepare_answer (24 h private freeze), law_publish_answer (public URL + separate revoke secret), law_revoke_answer | LAW_ANSWERS_URL and LAW_ANSWERS_TOKEN both set (read per call); else the tools answer “unavailable” | no local writes — the call is proxied to the external answers service, which owns the freeze, the public URL, and the revoke | no for the call itself; the frozen document lives in the answers service, not on any instance | only from the answers service |
| Neural appendix | law_search | LAW_NEURAL_URL set (token required by the client; read per call); else lexical-only answer | sends the query text to the external neural service; writes nothing locally | no | n/a (no state) |
| Case-package journal | law_case_propose, law_case_facts, law_case_decide, law_case_document, law_case_check | editing facet allowed | writes the case package on disk (one transaction) — the reason public slices deny editing | only if instances share the tree; otherwise the write lands on one instance’s files | only from backup |
| Draft checks and pins | law_check, law_pin, law_draft_*, law_measure | drafting facet allowed | writes working files/pins of the author tree — the reason public slices deny drafting | same-tree requirement as above | only from backup / VCS |
| Saved-answer explain | law_explain with a law://answer/ URI | artifact file present | reads the artifact from LAW_MCP_ARTIFACT_DIR (default $HOME/.cache/law-dsl/mcp-answers/<profile>); no tool in this server writes there — artifacts must arrive out-of-band (shared mount, copy step) | yes, unless the directory is shared: the reading instance must see the file | only from whoever saved it |
| Divergence reports | law_report | feedback facet allowed | server journal only; changes no law and no answer | no | n/a (journaled) |
| Always-on side channels | every call | journal enabled (default) / LAW_MCP_OTLP_URL set | call journal to the journald socket or stderr (never to files); OTLP spans out | n/a | n/a |
| Temp files | workbench runs | — | temp dirs + request.json under the OS temp dir; --export-neural-input writes its target file | n/a (per-call temp) | n/a |
Consequences used by the other articles:
- “The server writes no files” is true only with
editinganddraftingdenied, no export flags, and temp files excepted. Anything else, and the matrix row above names the writer. - “Nothing leaves the machine” additionally requires every
*_URLleg unset — no listening socket does not stop outbound calls. - Scale-out is affinity-free only for pure evaluation plus the two
remote legs. Case writes need a shared tree (or sticky
routing);
law://answer/reads need a shared artifact dir (or the owning instance).
Initiator identity and author fields: caller-asserted
Section titled “Initiator identity and author fields: caller-asserted”Fact provenance carries origin (default case_input), evidence
(a StableId), span (page/quote/offset), extractor
(name, version?, confidence?), and timestamps. All of it is
caller-asserted metadata: the server records it into
was_generated_by / was_derived_from edges and may warn
(FACT_WITHOUT_EVIDENCE when an extractor has no evidence), but
it verifies no signature, no author identity, and no extractor
claim. Journal field MCP_CLIENT (from X-Forwarded-For/socket) is
likewise observability, not authentication (see Authentication and access control). Treat
extractor.name as a label the caller chose, never as proof of who
produced the fact.
Operational tool profile vs model composition
Section titled “Operational tool profile vs model composition”- The operational tool profile is the server’s facet set, bound
at startup by
--profile. It is the enforcement point: hidden tools stay callable-shaped but answer-32002, andtools/listis its projection. - Model composition is whatever tool subset the agent harness
exposes to the model (system prompt, tool filter, per-task
allow-list). It is convenience and cost control, not a boundary:
a model that smuggles a
tools/callfor a composed-away tool succeeds whenever the server profile allows the facet. - Rule: put every real boundary in the server profile
(facets + instance token + separate processes per tenant); use
model composition only for focus. Audits check
tools/listand one direct-32002probe, never the harness config alone.
Expected result
Section titled “Expected result”On a slice denying drafting/adversarial/editing (the islam
shape): tools/list shows 6 facets’ tools; law_ask evaluates;
law_pin answers -32002 quoting the deny reason; law_nope
answers -32602.
Result check
Section titled “Result check”curl -s http://127.0.0.1:8725/mcp -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \ | python3 -c "import json,sys; print(sorted({t['name'] for t in json.load(sys.stdin)['result']['tools']}))"curl -s http://127.0.0.1:8725/mcp -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":2,"method":"tools/call","params":{"name":"law_pin","arguments":{}}}'Success: the name list contains no drafting/adversarial/editing
tool, and the second call returns code -32002. (Created-example
port; methods and codes are real.)
Failures and diagnostics
Section titled “Failures and diagnostics”| Symptom | Cause | Fix |
|---|---|---|
Startup: each known facet must be named exactly once | facet missing or in both lists | name all 9 exactly once |
Startup: facets.deny reason must be non-empty | blank reason | write a reason per denied facet |
Startup: corpus profile "x" not found | --profile typo | match the profile file name (<name>.json) |
-32002 for a tool the slice should serve | facet denied in the profile | move it to allow, restart |
-32602 for a real tool name | typo in name (e.g. law-ask) | use the exact law_* name |
| Denied tool still listed after profile edit | process not restarted | restart; facets bind at startup |
FACT_WITHOUT_EVIDENCE warning | fact has extractor but no evidence | attach provenance.evidence or drop the extractor claim |
Support boundaries
Section titled “Support boundaries”- Facets are the finest tool boundary the server supports: there is
no per-tool flag, per-predicate right, or per-user tool set.
Separate processes give instance isolation (own token, own
profile, own files), not finer authorization — one
executionfacet still bundles evaluation with answer preparation and publication in every process. “Evaluate but never publish” has no server-side switch: the nearest real mechanism is leaving the answers leg unwired (LAW_ANSWERS_URLunset), which turns all four publication tools into “unavailable” on that instance. - Deny reasons are human text in the profile; they are quoted in
-32002messages and must not contain secrets. extractor/originfields are unverified labels, not authorship proof. Provenance that must be trusted needs an operator-side signing/inventory process this server does not provide.- Science tools additionally require the profile to read
calc-multiple-testing; on other profiles they are hidden fromtools/listeven when their facet is allowed.
Next step
Section titled “Next step”- To guard the whole instance (token/loopback/public): Authentication and access control.
- For the full setting table behind
--profileand friends: Configuration reference.
Documentation for Arxo. Writings — blog.arxo.io.
Anonymous visit counts on stats.arxo.io, no cookies.