Skip to content
docs
Arxo ↗

Private MCP server: local stdio and own HTTP

For LLMs9 sections

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.

  • The law-mcp-server binary built from crate law-mcp ([[bin]] name = "law-mcp-server" in its Cargo.toml).

  • A checkout root holding the profiles directory and the CLIR tree (created-example /srv/law); root discovery uses these two markers when --root is absent. Each profile is one <name>.json file in the profiles directory.

  • A profile name from the profiles directory (real examples: kz, phys, chem, code-civil, de-bgb), or the default all.

  • For HTTP: a token the operator chose (created-example value below is not a real secret), via --token or LAW_MCP_TOKEN.

  • A dedicated OS user for the HTTP service (operator-policy-example; created-example lawsvc).

Real launch params (every flag/env below exists in main.rs Args::parse):

Terminal
law-mcp-server --root /srv/law --profile kz
  • Default when flags are absent: profile all (or LAW_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 --root fails 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/drafting allowed, 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):

JSON
{
"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).

Terminal
LAW_MCP_TOKEN=EXAMPLE-SYNTHETIC-TOKEN \
law-mcp-server --root /srv/law --profile kz --http \
--host 127.0.0.1 --port 8722

The 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.

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.

  • stdio: the client lists tools and law_ask answers inside the kz slice. No socket is bound — but that alone does not keep data on the machine: with any *_URL leg 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 /mcp with the token → tool answers; without the token → 401.
  • initialize negotiates one of the four real protocol revisions; a client asking an unknown revision gets the newest known one.
Terminal
# 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).

SymptomWhereMeaning and fix
unknown argument … + usage, exit 2startupflag typo; the usage line is authoritative
root discovery failurestartupcwd is outside a checkout; pass --root /srv/law
unknown profile errorstartupno such file in the profiles directory; list it
bind refused without tokenstartupnon-loopback --host needs --token or deliberate --public
401 + WWW-Authenticate: BearerPOST /mcpmissing/wrong Bearer token
400 on unknown protocol versionPOST /mcprefused outside initialize; initialize itself always passes version negotiation
405/4xx on GET /mcp/mcponly POST is served on /mcp
profile-scope refusal naming the profiletools/calltool facet denied by this profile; switch --profile, not flags
--healthcheck: unexpected /healthz responseprobeserver down, wrong port, or a non-MCP service answers there
  • “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-origin gates browser origins, not API clients.
  • --public / LAW_MCP_PUBLIC=1 without 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.

Documentation for Arxo. Writings — blog.arxo.io.

Anonymous visit counts on stats.arxo.io, no cookies.