# Tool profiles and input policy ## Goal 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. ## Scope 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 - A profile file `.json` in the checkout's profiles directory, or `all`. - A running instance started with `--profile ` (facets bind at startup; changing the file needs a restart). - A token or loopback access (see [Authentication and access control](/operate/authentication-access-control/)). ## 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 1. Write the profile: `allow` the facets the slice needs, `deny` the rest with a reason each (all 9 named exactly once). 2. Start with `--profile ` and read the stderr launch line for the profile name. 3. Call `tools/list` (real method, synthetic transport): the reply contains only allowed-facet tools, with the `facet` field stripped. Count them against the table above. 4. Call one denied tool directly (next section) and confirm `-32002`. ## 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): ```json {"jsonrpc": "2.0", "id": 7, "error": {"code": -32002, "message": "tool 'law_pin' ... facet 'drafting' ... "}} ``` 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 `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](/operate/authentication-access-control/)) may assert any predicate with any arguments. What the server does instead: - `package` on a fact disambiguates a short predicate name; it grants nothing. - Unknown predicates and unsatisfied premises surface as "not established" with a `whyNot` report — a logical outcome, not a refusal. - `law_screen` builds 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 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/`); 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 `editing` and `drafting` denied, no export flags, and temp files excepted. Anything else, and the matrix row above names the writer. - "Nothing leaves the machine" additionally requires every `*_URL` leg 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 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](/operate/authentication-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 - 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`, and `tools/list` is 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/call` for 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/list` and one direct `-32002` probe, never the harness config alone. ## 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 ```bash 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 | 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 (`.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 - 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 `execution` facet 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_URL` unset), which turns all four publication tools into "unavailable" on that instance. - Deny reasons are human text in the profile; they are quoted in `-32002` messages and must not contain secrets. - `extractor`/`origin` fields 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 from `tools/list` even when their facet is allowed. ## Next step - To guard the whole instance (token/loopback/public): [Authentication and access control](/operate/authentication-access-control/). - For the full setting table behind `--profile` and friends: [Configuration reference](/operate/configuration-reference/).