Markdown for LLMs
Private MCP server: local stdio and own HTTP
The source Markdown for this article. Copy it into your assistant or download it as a text file.
# 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
`<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`).
## 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 <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`).
### 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 <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
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.