From ab265b5b0c4413912784ecf3553d6f3ec746b997 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 16 Sep 2026 20:19:17 +0200 Subject: [PATCH] Serve MCP at a secret path, so claude.ai can connect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 //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) --- .env.example | 7 +++++ CLAUDE.md | 18 +++++++------ README.md | 10 +++++-- deploy/Caddyfile.snippet | 12 ++++++--- docs/AUTH.md | 23 ++++++++++++---- docs/DEPLOYMENT.md | 57 +++++++++++++++++++++++++++++++++------- docs/LOCAL.md | 4 +-- local-instance/README.md | 2 +- scripts/smoke.mjs | 24 ++++++++++++++--- src/bin/http.ts | 2 +- src/config.ts | 21 +++++++++++++++ src/http/auth.ts | 20 ++++++++++++++ src/http/server.ts | 23 ++++++++++++---- test/auth.test.ts | 38 ++++++++++++++++++++++++++- test/config.test.ts | 22 +++++++++++++++- 15 files changed, 240 insertions(+), 43 deletions(-) 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';