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

@@ -24,6 +24,13 @@ TSC_JWT_COOKIE=
# openssl rand -hex 32
MCP_AUTH_TOKEN=
# claude.ai only: serve MCP at /<this value>/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.

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.

View File

@@ -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 `/<secret>/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

View File

@@ -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 /<secret>/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 /<secret>/mcp
wrap json
}
}
}

View File

@@ -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: `/<secret>/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

View File

@@ -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 `/<secret>/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":"/<secret>/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=<openssl rand -hex 32>` 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/<secret>/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
`<secret>` 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 <MCP_AUTH_TOKEN>"
schulcloud login --server https://mcp.example.org --token <MCP_AUTH_TOKEN>
```
## Replacing the Schulcloud token
The token lasts 30 days at most and can only come from a browser login (see

View File

@@ -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:

View File

@@ -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

View File

@@ -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 /<secret>/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}`);

View File

@@ -70,7 +70,7 @@ async function main(): Promise<void> {
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'}, ` +

View File

@@ -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 `/<secret>/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',

View File

@@ -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());

View File

@@ -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<void> => {
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<void> => {
@@ -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;

View File

@@ -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<string, string>): { 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);
}
});
});

View File

@@ -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';