Serve MCP at a secret path, so claude.ai can connect

claude.ai's connector dialog takes a name and a URL. Sending a bearer token
needs a "Request headers" beta most accounts lack, and OAuth is not built
yet, so with MCP_PATH_SECRET set the endpoint is also served at
/<secret>/mcp without the bearer token — a trial until OAuth replaces it.

The path is the credential there. It is compared in constant time, and a
wrong one answers 404 like any unknown path. The config refuses fewer than 32
URL-safe characters and never echoes the value, nothing in the server logs
request paths, and the Caddy snippet rewrites the segment before an access
log entry is written (verified against Caddy 2.11). Claude Code and the CLI
keep the bearer token; DEPLOYMENT.md says what the path trades away.

178 tests. Smoke 76/76 and 74/74 on the local instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 20:19:17 +02:00
parent 973b82ebf5
commit ab265b5b0c
15 changed files with 240 additions and 43 deletions

View File

@@ -39,8 +39,8 @@ index.
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
`src/mcp/` or `src/http/` — the unit tests cover only pure functions.
Run smoke **both ways**: with `DATABASE_URL` set (74 checks, index-backed) and
without (72 checks, live-only). The degradation path is a supported mode, not a
Run smoke **both ways**: with `DATABASE_URL` set (76 checks, index-backed) and
without (74 checks, live-only). The degradation path is a supported mode, not a
fallback nobody exercises. Every Schulcloud check fails with 401 when the live
session has lapsed — check the container's keepalive log before suspecting code.
@@ -78,8 +78,9 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
- `session-token.ts` — the Schulcloud token, replaceable at runtime: checked
with `GET /me` (same `userId`), swapped into `config.jwt`, saved to
`STATE_DIR`. **Read `config.jwt` at the moment of use; never keep a copy.**
- **`http/`** — besides `/mcp` and `/api`: `/token`, a page that PUTs a fresh
token to `/api/token`. docs/AUTH.md says why it exists.
- **`http/`** — besides `/mcp` and `/api`: the optional `/<secret>/mcp` for
claude.ai (`MCP_PATH_SECRET`), and `/token`, a page that PUTs a fresh token to
`/api/token`. docs/AUTH.md and docs/DEPLOYMENT.md say why each exists.
- **`store/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS.
`Store.open` returns `undefined` when Postgres is down; callers degrade.
- **`indexer/`** — crawl → persist → mirror bytes → extract text → index.
@@ -128,8 +129,9 @@ user explicitly asking for one and understanding this.
**Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the
account; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in
logs, error messages, or tool output. `.env` is git-ignored — keep it that way.
One more counts as a secret: a token replaced at runtime, which lives only in
`STATE_DIR`, mode 0600.
Two more count as secrets: a token replaced at runtime (it lives only in
`STATE_DIR`, mode 0600) and, when `MCP_PATH_SECRET` is set, **request paths**
so nothing may log a URL path, and config errors describe the rule, not the value.
**Live behaviour beats upstream source.** The clones in `vendor/` track `main`
and may be ahead of what is deployed. When they disagree with the instance, the
@@ -328,8 +330,8 @@ bundle (2.1.272), not its docs:
## Environment
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`; docker-compose sets
`STATE_DIR`. See `.env.example` for the
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`, and optionally
`MCP_PATH_SECRET`; docker-compose sets `STATE_DIR`. See `.env.example` for the
full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`,
no restart. `npm run probe` and `schulcloud token` report the clocks: days until
hard expiry and the session budget.