diff --git a/.env.example b/.env.example index c3ef2bd..daa197d 100644 --- a/.env.example +++ b/.env.example @@ -24,6 +24,13 @@ TSC_JWT_COOKIE= # openssl rand -hex 32 MCP_AUTH_TOKEN= +# claude.ai only: serve MCP at //mcp WITHOUT the bearer token, +# because its connector dialog cannot send a header. The URL becomes the +# credential — see "Connecting Claude" in docs/DEPLOYMENT.md before using it. +# At least 32 URL-safe characters; unset = off. Generate one with: +# openssl rand -hex 32 +# MCP_PATH_SECRET= + # Where a Schulcloud token replaced at runtime (`schulcloud token set`, /token) # is saved, so a restart keeps it. docker-compose.yml sets /data/state; unset = # replacements last until the next restart. diff --git a/CLAUDE.md b/CLAUDE.md index f1b0345..9b3c8e1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 `//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. diff --git a/README.md b/README.md index 18c9fe3..85c4e12 100644 --- a/README.md +++ b/README.md @@ -115,6 +115,12 @@ copy is the browser's own session token**, so a Schulportal tab left open will auto-logout after ~2 hours and revoke this server's token with it. Copy the token in a private window and close it. See [docs/AUTH.md](docs/AUTH.md). +**claude.ai reaches it by a secret path, for now.** Its connector dialog takes +a URL and no header, so `MCP_PATH_SECRET` serves MCP at `//mcp` without +the bearer token — never logged, redacted by the Caddy snippet, and a stopgap +until the endpoint speaks OAuth. Claude Code and the CLI keep the bearer token. +See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md). + **Read-only by construction.** Every method on the API client is a `GET`, including `api_get`. The endpoint is internet-facing by necessity (Claude's connectors call it from Anthropic's cloud), so the fact that a leaked token @@ -177,8 +183,8 @@ npm run typecheck ``` `npm run smoke` starts the HTTP server, connects a real MCP client over -Streamable HTTP and exercises every tool against the live account — 72 checks (74 with the index) -covering the auth gate, the protocol handshake, every content chain, file +Streamable HTTP and exercises every tool against the live account — 74 checks (76 with the index) +covering the auth gate and the secret path, the protocol handshake, every content chain, file extraction, resources and prompts, token replacement, `api_get`'s guard rails and error handling. ## Upstream diff --git a/deploy/Caddyfile.snippet b/deploy/Caddyfile.snippet index 0d4b07e..6c3d1c3 100644 --- a/deploy/Caddyfile.snippet +++ b/deploy/Caddyfile.snippet @@ -38,9 +38,13 @@ mcp.example.org { log { output file /var/log/caddy/schulcloud-mcp.log - format json - # Request URLs are not secrets here (the token is in a header, not the - # path), but the Authorization header must never be written to disk. - # Caddy does not log headers by default; do not add them. + # With MCP_PATH_SECRET set, a request path *is* a credential: claude.ai + # reaches the server at //mcp. The filter rewrites that segment + # before the entry is written. The Authorization header must never reach + # disk either; Caddy does not log headers by default — do not add them. + format filter { + request>uri regexp ^/[A-Za-z0-9_-]{32,}/mcp //mcp + wrap json + } } } diff --git a/docs/AUTH.md b/docs/AUTH.md index e2c1b1d..177a3e8 100644 --- a/docs/AUTH.md +++ b/docs/AUTH.md @@ -192,16 +192,29 @@ a shared secret checked in constant time on every `/mcp` request Generate one with `openssl rand -hex 32`. If it is unset the server logs a loud warning and serves unauthenticated — only acceptable bound to localhost. -Rotating it: change `MCP_AUTH_TOKEN` in `.env`, restart the container, update -the connector in Claude. Nothing else stores it. +Rotating it: change `MCP_AUTH_TOKEN` in `.env`, recreate the container, and +update Claude Code and the CLI (`schulcloud login`). Nothing else stores it. + +### The secret path, for claude.ai + +claude.ai's connector dialog takes a URL and no header, so `MCP_PATH_SECRET` +opens a second way in: `//mcp`, with no bearer token. The path is the +credential there. `http/auth.ts` compares it in constant time and answers a +wrong one with the same 404 as any unknown path; `config.ts` insists on at +least 32 URL-safe characters and never echoes the value; the server never logs +request paths; and `deploy/Caddyfile.snippet` rewrites the segment before an +access log entry is written. Rotating it means a new value, a recreated +container, and re-adding the connector. It is a stopgap: OAuth is how +connectors are meant to authenticate, and it would make the URL a plain +address again. ## Blast radius Every path in this server is a `GET`, including the `api_get` escape hatch, which rejects anything not starting with `/api/` and anything carrying a scheme -or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` could -read this account's Schulcloud data; they could not post, submit, delete, or -otherwise act as the user. With `MCP_AUTH_TOKEN` they +or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` — or +the secret MCP path — could read this account's Schulcloud data; they could not +post, submit, delete, or otherwise act as the user. With `MCP_AUTH_TOKEN` they could also call `PUT /api/token`, but it accepts only a live token for the same account, so the most it can do is hand the server a session the owner already has. Keep it that way — adding a single write tool would change that property diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 5146afd..6c9d747 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -13,14 +13,16 @@ schulcloud CLI ─────┘ └─ Caddy └──▶ schulcloud-thueringen.de ``` -Both front ends use the same hostname and the same bearer token. `/mcp` speaks MCP; `/api` serves the +Both front ends use the same hostname. `/mcp` speaks MCP; `/api` serves the CLI's manifest, file bytes, re-crawl requests and token replacement; `/token` is a page for pasting a fresh Schulcloud token. Claude's custom connectors call the endpoint from Anthropic's cloud (`160.79.104.0/21`), so it must be publicly reachable over real TLS — a localhost tunnel or self-signed cert will not do. The VPS provides the public -address; Caddy on the Pi terminates TLS and obtains the certificate. +address; Caddy on the Pi terminates TLS and obtains the certificate. **Keep the +VPS forwarding raw TCP** rather than terminating TLS itself: then it never sees +a request path, which matters once a path carries a secret (below). **The Pi must stay up.** More than two hours offline ends the Schulcloud session however long the token has left — a laptop that sleeps overnight loses it every @@ -40,6 +42,7 @@ cd /opt/schulcloud-mcp cp .env.example .env # Fill in TSC_URL and TSC_JWT_COOKIE (see docs/AUTH.md), then: openssl rand -hex 32 # → MCP_AUTH_TOKEN +openssl rand -hex 32 # → MCP_PATH_SECRET, only for claude.ai (see "Connecting Claude") docker compose up -d --build docker compose logs -f schulcloud-mcp @@ -111,6 +114,9 @@ Two settings in that snippet matter and are easy to miss: connector hangs with no error. - **`read_timeout`/`write_timeout` of 300s** — a `search` call walks every course and can take tens of seconds. Caddy's defaults will cut it off. +- **The `format filter` in `log`** — rewrites `//mcp` before an access + log entry is written. Without it every claude.ai request writes the secret to + disk. Verified against Caddy 2.11: the entry reads `"uri":"//mcp"`. ## Ports and DNS @@ -128,6 +134,10 @@ curl -s https://mcp.example.org/healthz curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/mcp \ -H 'content-type: application/json' -d '{}' # 401 ← the bearer check is live + +curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/$(openssl rand -hex 32)/mcp \ + -H 'content-type: application/json' -d '{}' +# 404 ← a wrong path secret looks like any unknown path ``` If `/healthz` answers but `/mcp` returns 401 with a correct token, check that @@ -136,16 +146,45 @@ newline from a copy-paste. ## Connecting Claude -1. claude.ai → **Settings → Connectors → Add custom connector**. -2. URL: `https://mcp.example.org/mcp` -3. Under **Advanced settings**, add the bearer token as an authorization - header. If your organisation has no header-auth field, the server also - accepts the token as `X-Api-Key`. -4. Enable the connector in a conversation via **+ → Add connectors**. +### claude.ai — a secret path, for now -Ask *"which courses am I in?"* as a first check — that exercises auth, the +claude.ai's *Add custom connector* dialog takes a name and a URL. Sending a +bearer token needs its "Request headers" section, a beta most accounts do not +have, and the proper answer — OAuth — is not built yet. Until it is, the +server can serve MCP at a path that is itself the secret: + +1. Put `MCP_PATH_SECRET=` in `.env` and recreate the + container: `docker compose up -d --force-recreate schulcloud-mcp`. The + startup line then says `(plus secret MCP path)` — never the secret itself. +2. claude.ai → **Customize → Connectors → Add custom connector**. Name it, and + give the URL `https://mcp.example.org//mcp`. No sign-in. +3. Enable it in a conversation via **+ → Connectors**. + +Ask *"which courses am I in?"* as a first check — that exercises the path, the Schulcloud token and the API in one call. +**Know what this trades away.** The URL is now the credential, and Anthropic's +connector documentation calls credentials in URLs a security vulnerability, +because URLs end up in logs. This deployment keeps it out of its own: the +server never logs request paths, the Caddy snippet rewrites the segment to +`` in the access log, and a VPS forwarding raw TCP never sees it. +Caddy's *error* log can still name the path if the container is down while +claude.ai calls, and claude.ai stores the URL in its connector settings. There +is no revocation short of a new secret: change `MCP_PATH_SECRET`, recreate the +container, and add the connector again. Anyone holding the URL can read — never +change — the account. A wrong secret answers 404, like any unknown path. + +### Claude Code and the CLI — the bearer token + +Both can send a header, so they keep using `MCP_AUTH_TOKEN` on the plain `/mcp` +and `/api`: + +```bash +claude mcp add --transport http --scope user schulcloud https://mcp.example.org/mcp \ + --header "Authorization: Bearer " +schulcloud login --server https://mcp.example.org --token +``` + ## Replacing the Schulcloud token The token lasts 30 days at most and can only come from a browser login (see diff --git a/docs/LOCAL.md b/docs/LOCAL.md index 0ad73a4..d72d4c6 100644 --- a/docs/LOCAL.md +++ b/docs/LOCAL.md @@ -107,9 +107,9 @@ node dist/bin/cli.js sync ## Run the test suites ```bash -npm test # 174 offline tests +npm test # 178 offline tests npm run smoke # end-to-end against the live instance, live-only mode -DATABASE_URL=… npm run smoke # end-to-end with the index (74 checks) +DATABASE_URL=… npm run smoke # end-to-end with the index (76 checks) ``` Store tests need a database and skip without one: diff --git a/local-instance/README.md b/local-instance/README.md index 3728886..5db810b 100644 --- a/local-instance/README.md +++ b/local-instance/README.md @@ -217,7 +217,7 @@ be pointed at the instance in between: ```bash eval "$(./scripts/mcp-env.sh)" # as the demo student -cd .. && npm run smoke # 74 checks against the local instance +cd .. && npm run smoke # 76 checks against the local instance ``` `mcp-env.sh` points the index at its own database, `schulcloud_local`, and the diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index ad6fb23..b37bd66 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -6,6 +6,7 @@ * Requires TSC_URL and TSC_JWT_COOKIE in the environment (load .env first). * Read-only — it never writes to Schulcloud. */ +import { randomBytes } from 'node:crypto'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; @@ -17,6 +18,8 @@ import { closeServices, createServices } from '../dist/services.js'; const TOKEN = 'smoke-test-token-' + Math.random().toString(36).slice(2); process.env.MCP_AUTH_TOKEN = TOKEN; +const PATH_SECRET = randomBytes(32).toString('hex'); +process.env.MCP_PATH_SECRET = PATH_SECRET; // A state directory of its own, so the run can neither read nor leave a saved token. const STATE_DIR = await mkdtemp(join(tmpdir(), 'schulcloud-smoke-state-')); process.env.STATE_DIR = STATE_DIR; @@ -470,12 +473,25 @@ console.log('\n== error handling =='); const bogus = await call('get_course', { courseId: '000000000000000000000000' }); check('unknown id returns a tool error, not a crash', bogus.isError, bogus.text.split('\n')[0]); -console.log('\n== session token =='); -// The Schulcloud token can be replaced at runtime. Nothing here replaces the -// live token: the one PUT that succeeds sends the token already in use, which -// the server answers without a swap. +console.log('\n== secret path and session token =='); +// claude.ai's connector dialog takes only a URL, so //mcp serves MCP +// without a bearer token; and the Schulcloud token can be replaced at runtime. +// Nothing here replaces the live token: the one PUT that succeeds sends the +// token already in use, which the server answers without a swap. { const root = `http://127.0.0.1:${port}`; + const wrong = await fetch(`${root}/${'f'.repeat(64)}/mcp`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }), + }); + check('a wrong path secret looks like any unknown path (404)', wrong.status === 404, `got ${wrong.status}`); + + const viaSecret = new Client({ name: 'smoke-secret-path', version: '0' }, { capabilities: {} }); + await viaSecret.connect(new StreamableHTTPClientTransport(new URL(`${root}/${PATH_SECRET}/mcp`))); + const secretTools = await viaSecret.listTools(); + check('the secret path serves MCP without a bearer token', secretTools.tools.length === tools.length, `${secretTools.tools.length} tools`); + await viaSecret.close(); const anonymous = await fetch(`${root}/api/token`); check('/api/token needs the bearer token', anonymous.status === 401, `got ${anonymous.status}`); diff --git a/src/bin/http.ts b/src/bin/http.ts index 91001a0..8db4cbe 100644 --- a/src/bin/http.ts +++ b/src/bin/http.ts @@ -70,7 +70,7 @@ async function main(): Promise { const token = services.session.status(); console.log( `[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` + - `auth ${config.authToken ? 'enabled' : 'DISABLED'}, ` + + `auth ${config.authToken ? 'enabled' : 'DISABLED'}${config.mcpPathSecret ? ' (plus secret MCP path)' : ''}, ` + `token from ${token.source}${token.daysLeft === undefined ? '' : `, ${token.daysLeft} day(s) left`}` + `${token.persistent ? '' : ' (replacements not saved: STATE_DIR unset)'}, ` + `keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}, ` + diff --git a/src/config.ts b/src/config.ts index fdda6bc..af1bcaf 100644 --- a/src/config.ts +++ b/src/config.ts @@ -20,6 +20,12 @@ export interface Config { jwt: string; /** Shared secret callers must present to this MCP server. Unused in stdio mode. */ authToken: string | undefined; + /** + * Serves MCP at `//mcp` without a bearer token, for clients that can + * send none — claude.ai's connector dialog takes only a URL. The path is then + * the credential, so it must never be logged. + */ + mcpPathSecret: string | undefined; /** Where state that must survive a restart is kept: a replaced session token. Unset = memory only. */ stateDir: string | undefined; port: number; @@ -73,6 +79,20 @@ function int(name: string, fallback: number): number { return parsed; } +/** A URL-safe secret of at least 32 characters, or undefined when unset. */ +function pathSecret(name: string): string | undefined { + const value = process.env[name]?.trim(); + if (!value) return undefined; + // The value is a credential: the error states the rule and never echoes it. + if (!/^[A-Za-z0-9_-]{32,}$/.test(value)) { + throw new Error( + `Environment variable ${name} must be at least 32 characters of A-Z, a-z, 0-9, "-" or "_". ` + + 'Generate one with: openssl rand -hex 32', + ); + } + return value; +} + /** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */ function intAllowingZero(name: string, fallback: number): number { const raw = process.env[name]?.trim(); @@ -89,6 +109,7 @@ export function loadConfig(): Config { baseUrl: required('TSC_URL').replace(/\/+$/, ''), jwt: required('TSC_JWT_COOKIE'), authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined, + mcpPathSecret: pathSecret('MCP_PATH_SECRET'), stateDir: process.env.STATE_DIR?.trim() ? resolve(process.env.STATE_DIR.trim()) : undefined, port: int('PORT', 8080), bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0', diff --git a/src/http/auth.ts b/src/http/auth.ts index a902b17..8d73efa 100644 --- a/src/http/auth.ts +++ b/src/http/auth.ts @@ -28,6 +28,26 @@ export function bearerAuth(expected: string) { }; } +/** + * Gate for `/:secret/mcp`, the header-free way in. + * + * A wrong secret answers exactly like any other unknown path, so guessing + * learns nothing — not even that the route exists. The comparison is + * constant-time for the same reason as the bearer check's. + */ +export function pathSecret(expected: string) { + const expectedBytes = Buffer.from(expected, 'utf8'); + + return function checkPathSecret(req: Request, res: Response, next: NextFunction): void { + const presented = req.params.secret; + if (typeof presented !== 'string' || !constantTimeEquals(Buffer.from(presented, 'utf8'), expectedBytes)) { + res.status(404).json({ error: 'not_found' }); + return; + } + next(); + }; +} + function extractToken(authorization: string | undefined, apiKey: string | undefined): string | undefined { if (authorization) { const match = /^Bearer\s+(.+)$/i.exec(authorization.trim()); diff --git a/src/http/server.ts b/src/http/server.ts index e6e72df..f9518aa 100644 --- a/src/http/server.ts +++ b/src/http/server.ts @@ -6,7 +6,7 @@ import type { Config } from '../config.ts'; import { createServer } from '../mcp/server.ts'; import type { Services } from '../services.ts'; import { createApiRouter } from './api.ts'; -import { bearerAuth } from './auth.ts'; +import { bearerAuth, pathSecret } from './auth.ts'; import { tokenPage, tokenScript } from './token-page.ts'; /** @@ -78,9 +78,7 @@ export function createHttpApp(config: Config, services?: Services): express.Expr app.get('/token.js', tokenScript); } - app.use(MCP_PATH, express.json({ limit: '4mb' })); - - app.post(MCP_PATH, async (req: Request, res: Response) => { + const handlePost = async (req: Request, res: Response): Promise => { const sessionId = req.get('mcp-session-id'); try { @@ -129,7 +127,9 @@ export function createHttpApp(config: Config, services?: Services): express.Expr console.error('[schulcloud-mcp] POST failed:', error); if (!res.headersSent) res.status(500).json(rpcError(-32603, 'Internal server error')); } - }); + }; + + app.post(MCP_PATH, express.json({ limit: '4mb' }), handlePost); // GET opens the server→client SSE stream; DELETE ends the session. const bySession = async (req: Request, res: Response): Promise => { @@ -151,6 +151,19 @@ export function createHttpApp(config: Config, services?: Services): express.Expr app.get(MCP_PATH, bySession); app.delete(MCP_PATH, bySession); + // The same endpoint without a bearer token, for clients that cannot send one: + // claude.ai's connector dialog takes only a URL. The path is the credential + // here, so nothing in this server logs request paths — keep it that way — and + // the Caddy snippet redacts it from the access log. A stopgap until the + // endpoint speaks OAuth, which is what connectors are meant to use. + if (config.mcpPathSecret) { + const secretMcpPath = '/:secret/mcp'; + const gate = pathSecret(config.mcpPathSecret); + app.post(secretMcpPath, gate, express.json({ limit: '4mb' }), handlePost); + app.get(secretMcpPath, gate, bySession); + app.delete(secretMcpPath, gate, bySession); + } + app.use((_req, res) => res.status(404).json({ error: 'not_found' })); return app; diff --git a/test/auth.test.ts b/test/auth.test.ts index c41d3eb..ac070de 100644 --- a/test/auth.test.ts +++ b/test/auth.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { describe, it } from 'node:test'; -import { bearerAuth } from '../src/http/auth.ts'; +import { bearerAuth, pathSecret } from '../src/http/auth.ts'; function run(headers: Record): { status?: number; passed: boolean } { const middleware = bearerAuth('correct-horse-battery-staple'); @@ -53,3 +53,39 @@ describe('bearerAuth', () => { } }); }); + +describe('pathSecret', () => { + const secret = 'a'.repeat(40) + 'B-_9'; + + function visit(presented: unknown): { status?: number; passed: boolean } { + const middleware = pathSecret(secret); + let status: number | undefined; + let passed = false; + const req = { params: { secret: presented } } as never; + const res = { + status(code: number) { + status = code; + return this; + }, + json() { + return this; + }, + } as never; + middleware(req, res, () => { + passed = true; + }); + return { status, passed }; + } + + it('lets the exact secret through', () => { + assert.equal(visit(secret).passed, true); + }); + + it('answers anything else like an unknown path, not like a refused login', () => { + for (const presented of [undefined, '', 'mcp', secret.slice(0, -1), `${secret}x`, secret.toUpperCase()]) { + const result = visit(presented); + assert.equal(result.passed, false, `should reject ${JSON.stringify(presented)}`); + assert.equal(result.status, 404); + } + }); +}); diff --git a/test/config.test.ts b/test/config.test.ts index 19e89d8..f5e10bc 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -35,7 +35,27 @@ describe('loadConfig', () => { }); }); -describe('loadConfig: state directory', () => { +describe('loadConfig: secret MCP path and state directory', () => { + it('accepts a long URL-safe secret and leaves it off by default', () => { + process.env.TSC_URL = 'https://example.org'; + process.env.TSC_JWT_COOKIE = 'x'; + assert.equal(loadConfig().mcpPathSecret, undefined); + process.env.MCP_PATH_SECRET = '0123456789abcdef0123456789abcdef'; + assert.equal(loadConfig().mcpPathSecret, '0123456789abcdef0123456789abcdef'); + }); + + it('refuses a short or unsafe secret without echoing it', () => { + process.env.TSC_URL = 'https://example.org'; + process.env.TSC_JWT_COOKIE = 'x'; + for (const secret of ['short-secret', 'has spaces in it but is long enough 1234', 'slash/in/the/middle/0123456789abcdefgh']) { + process.env.MCP_PATH_SECRET = secret; + assert.throws( + () => loadConfig(), + (error: Error) => /MCP_PATH_SECRET/.test(error.message) && !error.message.includes(secret), + ); + } + }); + it('resolves the state directory to an absolute path', () => { process.env.TSC_URL = 'https://example.org'; process.env.TSC_JWT_COOKIE = 'x';