# Private MCP server: local stdio and own HTTP ## Goal 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. ## Scope 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 - 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 `.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`). ## Steps ### Step 1: Local stdio server Real launch params (every flag/env below exists in `main.rs` `Args::parse`): ```bash 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](/operate/tool-profiles-input-policy/#effects-the-authoritative-matrix): with `editing`/`drafting` allowed, case packages and author-tree files change on disk; `--export-neural-input ` 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": , "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 ```bash 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](/operate/authentication-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 ` (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 `; 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 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 - 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](/operate/tool-profiles-input-policy/#effects-the-authoritative-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. ## Result check ```bash # 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 | 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 - "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. ## Next step - [01 Deployment overview](/operate/deployment-overview/) to compare surfaces. - [02 Private HTTP service](/operate/private-http-service/) when the consumer needs a pinned world with a replayable decision journal instead of MCP tools.