Private MCP server: local stdio and own HTTP
Run a private Arxo MCP server two ways: as a local stdio subprocess for one agent or editor, and as your own HTTP MCP endpoint for network clients — with the right profile, authentication where the tool provides it, and loud failures everywhere else.
Component law-mcp-server binary (crate law-mcp; usage in
src/main.rs). Scenario: private single-organization use; stdio for
local clients, HTTP on loopback with a token (plus an operator TLS edge
for remote clients). All paths, tokens, and profile names that are not
shipped profile names are synthetic.
Prerequisites
Section titled “Prerequisites”-
The
law-mcp-serverbinary built from cratelaw-mcp([[bin]] name = "law-mcp-server"in itsCargo.toml). -
A checkout root holding the profiles directory and the CLIR tree (created-example
/srv/law); root discovery uses these two markers when--rootis absent. Each profile is one<name>.jsonfile in the profiles directory. -
A profile name from the profiles directory (real examples:
kz,phys,chem,code-civil,de-bgb), or the defaultall. -
For HTTP: a token the operator chose (created-example value below is not a real secret), via
--tokenorLAW_MCP_TOKEN. -
A dedicated OS user for the HTTP service (operator-policy-example; created-example
lawsvc).
Step 1: Local stdio server
Section titled “Step 1: Local stdio server”Real launch params (every flag/env below exists in main.rs
Args::parse):
law-mcp-server --root /srv/law --profile kz- Default when flags are absent: profile
all(orLAW_MCP_PROFILE), root found by walking up from the cwd to a directory holding the profiles directory and the CLIR tree. So cwd matters: starting from the wrong directory without--rootfails root discovery. Prefer an explicit--root(reference-setting). - Transport: JSON-RPC objects as lines on stdin, one response line per
request on stdout; blank lines skipped, unparseable lines ignored
(
serve_stdio). There is no authentication on stdio: the OS process boundary is the trust boundary — anything that can write to the process stdin can ask. - File access: the server reads the corpus slices and profiles under the
root. Which tools write, and where, is governed by the
effects matrix:
with
editing/draftingallowed, case packages and author-tree files change on disk;--export-neural-input <path>is a deliberate export command, not a serving mode — and temp workbench files are always possible.
Client connect (created-example: client config formats are defined by each MCP client, not by this repo):
{ "mcpServers": { "arxo-kz": { "command": "law-mcp-server", "args": ["--root", "/srv/law", "--profile", "kz"] } }}Discovery, real protocol: the client sends initialize (server answers
with serverInfo: {"name": <profile.serverName>, "version": "0.1.0"} and
a negotiated MCP-Protocol-Version chosen from 2025-11-25,
2025-06-18, 2025-03-26, 2024-11-05, newest first), then
tools/list receives the tool table filtered by the profile’s
facets_allow/facets_deny (src/server.rs, src/profile.rs).
Step 2: Own HTTP MCP server
Section titled “Step 2: Own HTTP MCP server”LAW_MCP_TOKEN=EXAMPLE-SYNTHETIC-TOKEN \law-mcp-server --root /srv/law --profile kz --http \ --host 127.0.0.1 --port 8722The token travels by exactly one channel here: the environment.
Do not combine both channels in one command line — VAR=value command --token "$VAR" expands $VAR from the caller’s
environment before the assignment applies, so --token would
receive the old value (or an empty string) while the child sees
the new one. Since the flag wins over the environment, the
instance would then enforce a token you did not intend. For a
systemd service, keep the token in the instance EnvironmentFile
(see Authentication and access control)
and pass no --token flag at all.
Real params and defaults: --http enables HTTP mode; --host
(default 127.0.0.1, or LAW_MCP_HOST), --port (default 8722, or
LAW_MCP_PORT), --token (or LAW_MCP_TOKEN), --public (or
LAW_MCP_PUBLIC=1, deliberate consent to an unauthenticated bind),
--allowed-origin <url> (repeatable; or comma-separated
LAW_MCP_ALLOWED_ORIGINS), --healthcheck [--port] (probe mode),
LAW_MCP_CALL_TIMEOUT (seconds, fractions allowed, 0/empty = no
limit; the code comment names 110 as a deployment value under a 120 s
proxy timeout), LAW_MCP_LOG=0 (disable the call journal),
LAW_MCP_LOG_MAX_BODY, LAW_MCP_LOG_SOCKET, LAW_MCP_OTLP_URL.
Real endpoints: POST /mcp (JSON-RPC; GET/DELETE /mcp are refused),
GET /healthz (JSON starting with "status":"ok" plus process fields —
pid, profile, overrunCalls, and fixed compatibility fields; used by
--healthcheck).
With a token set, POST /mcp requires Authorization: Bearer <token>;
otherwise 401 with WWW-Authenticate: Bearer realm="law-mcp".
A non-loopback --host without a token is refused at startup unless
--public/LAW_MCP_PUBLIC=1 (deliberate-consent rule: an open evaluator never comes up by typo).
Remote clients need TLS, which the server does not terminate: put the
same kind of operator reverse proxy in front as in article 02
(created-example, operator layer), keep --host 127.0.0.1, and keep the
Bearer check on.
Step 3: Profiles: what the client may see
Section titled “Step 3: Profiles: what the client may see”A profile (one JSON file in the profiles directory) selects the corpus slice by
prefixes + borrowed package names, the tool facets by
facets_allow/facets_deny (nine facets: reference, execution,
drafting, editing, argumentation, adversarial, process,
editions, feedback), the surface language, and the server name the
client sees in initialize. Asking for a tool outside the profile’s
facets is answered with a profile-scope refusal naming the profile — not
with an empty result.
Expected result
Section titled “Expected result”- stdio: the client lists tools and
law_askanswers inside thekzslice. No socket is bound — but that alone does not keep data on the machine: with any*_URLleg set (answers, neural, OTLP), stdio calls still reach out. “Nothing leaves” holds only with every outbound leg unset (see the effects matrix). - HTTP:
GET /healthz→ 200 with"status":"ok"among other fields;POST /mcpwith the token → tool answers; without the token → 401. initializenegotiates one of the four real protocol revisions; a client asking an unknown revision gets the newest known one.
Result check
Section titled “Result check”# stdio smoke: one tools/list over a pipe (real method names)echo '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \ | law-mcp-server --root /srv/law --profile kz | head -c 300; echo# HTTP health (real probe the binary ships)law-mcp-server --healthcheck --port 8722 && echo HEALTH-OK# HTTP auth check (synthetic token value, real status codes)curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8722/mcp \ -H 'content-type: application/json' \ -d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}'Expected: a JSON tool list on stdout; HEALTH-OK; 401 without the
Authorization header (200 with it).
Failures and diagnostics
Section titled “Failures and diagnostics”| Symptom | Where | Meaning and fix |
|---|---|---|
unknown argument … + usage, exit 2 | startup | flag typo; the usage line is authoritative |
| root discovery failure | startup | cwd is outside a checkout; pass --root /srv/law |
| unknown profile error | startup | no such file in the profiles directory; list it |
| bind refused without token | startup | non-loopback --host needs --token or deliberate --public |
401 + WWW-Authenticate: Bearer | POST /mcp | missing/wrong Bearer token |
| 400 on unknown protocol version | POST /mcp | refused outside initialize; initialize itself always passes version negotiation |
405/4xx on GET /mcp | /mcp | only POST is served on /mcp |
| profile-scope refusal naming the profile | tools/call | tool facet denied by this profile; switch --profile, not flags |
--healthcheck: unexpected /healthz response | probe | server down, wrong port, or a non-MCP service answers there |
Support boundaries
Section titled “Support boundaries”- “Private” for stdio means only: reachable through the spawning client’s process boundary, no socket, no auth inside the tool. Any local process running as the same user with pipe access can ask.
- “Private” for HTTP means: loopback bind + Bearer token + operator TLS
edge. The server itself does no TLS, no rate limiting, no per-client
quotas;
--allowed-origingates browser origins, not API clients. --public/LAW_MCP_PUBLIC=1without a token is explicit consent to an open evaluator (reference-setting for deliberate exposure, never a default).- Client config JSON, proxy configs, user accounts, and token values in this article are created-examples: operator sketches, not tested artifacts. Only the flags, env vars, endpoints, status codes, and protocol revisions exist in the cited code.
Next step
Section titled “Next step”- 01 Deployment overview to compare surfaces.
- 02 Private HTTP service when the consumer needs a pinned world with a replayable decision journal instead of MCP tools.
Documentation for Arxo. Writings — blog.arxo.io.
Anonymous visit counts on stats.arxo.io, no cookies.