docs← Back to article

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.

Download this articlePlain text ↗
# 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.