Give claude.ai a token of its own, sent as a request header

claude.ai's connector dialog does offer request headers, on its second step,
after the URL has been probed, so the connector no longer needs the secret
path. MCP_AUTH_TOKEN already worked there as a bearer or X-Api-Key, but it
also opens /api, which can replace the Schulcloud token and stream the file
mirror, and claude.ai stores the header's value.

MCP_CONNECTOR_TOKEN is a second token, accepted on /mcp only and refused on
/api, and rotated without touching Claude Code or the CLI. The config refuses
one shorter than 32 characters, equal to MCP_AUTH_TOKEN, or set without it,
and never echoes a value. Every accepted token is compared in full, so the
timing does not tell which one matched.

The gate also takes a bare Authorization value, because claude.ai sends a
header exactly as typed and its docs warn that most servers reject a token
entered without "Bearer ". It takes X-Auth-Token too, the other name its
dialog offers.

The docs now set up the header; the secret path stays as a fallback for
clients that cannot send one. 184 tests. Smoke 79/79 and 77/77 on the local
instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 22:03:56 +02:00
parent bfccb3f343
commit ab581aa5ca
15 changed files with 279 additions and 79 deletions

View File

@@ -70,7 +70,8 @@ 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'}${config.mcpPathSecret ? ' (plus secret MCP path)' : ''}, ` +
`auth ${config.authToken ? 'enabled' : 'DISABLED'}` +
`${config.connectorToken ? ' (plus connector token)' : ''}${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,13 @@ export interface Config {
jwt: string;
/** Shared secret callers must present to this MCP server. Unused in stdio mode. */
authToken: string | undefined;
/**
* A second token, accepted on `/mcp` only: the one claude.ai's connector
* sends as a request header. It is stored by a third party, so it opens the
* read-only MCP tools and not `/api`, which can replace the session token
* and stream the file mirror — and it can be revoked on its own.
*/
connectorToken: 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
@@ -93,6 +100,20 @@ function pathSecret(name: string): string | undefined {
return value;
}
/** A token of at least 32 characters without whitespace, or undefined when unset. */
function secretToken(name: string): string | undefined {
const value = process.env[name]?.trim();
if (!value) return undefined;
// A credential: the error states the rule and never echoes the value.
if (value.length < 32 || /\s/.test(value)) {
throw new Error(
`Environment variable ${name} must be at least 32 characters without spaces. ` +
'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();
@@ -105,10 +126,21 @@ function intAllowingZero(name: string, fallback: number): number {
}
export function loadConfig(): Config {
const authToken = process.env.MCP_AUTH_TOKEN?.trim() || undefined;
const connectorToken = secretToken('MCP_CONNECTOR_TOKEN');
if (connectorToken && !authToken) {
// Without the main token /api would be open while /mcp is not.
throw new Error('MCP_CONNECTOR_TOKEN needs MCP_AUTH_TOKEN as well, or /api would be left unauthenticated.');
}
if (connectorToken && connectorToken === authToken) {
throw new Error('MCP_CONNECTOR_TOKEN must differ from MCP_AUTH_TOKEN, or it cannot be limited to /mcp or revoked on its own.');
}
return {
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
jwt: required('TSC_JWT_COOKIE'),
authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined,
authToken,
connectorToken,
mcpPathSecret: pathSecret('MCP_PATH_SECRET'),
stateDir: process.env.STATE_DIR?.trim() ? resolve(process.env.STATE_DIR.trim()) : undefined,
port: int('PORT', 8080),

View File

@@ -9,13 +9,21 @@ import type { NextFunction, Request, Response } from 'express';
* the token is the only thing between a stranger and the account's data.
* Comparison is constant-time, and a miss returns a bare 401 with a
* `WWW-Authenticate` challenge and no detail about why.
*
* A route can accept more than one token: `/mcp` also takes the connector
* token claude.ai stores, which `/api` refuses.
*/
export function bearerAuth(expected: string) {
const expectedBytes = Buffer.from(expected, 'utf8');
export function bearerAuth(accepted: string | string[]) {
const expected = (Array.isArray(accepted) ? accepted : [accepted]).map((token) => Buffer.from(token, 'utf8'));
return function authenticate(req: Request, res: Response, next: NextFunction): void {
const presented = extractToken(req.get('authorization'), req.get('x-api-key'));
if (presented === undefined || !constantTimeEquals(Buffer.from(presented, 'utf8'), expectedBytes)) {
const presented = extractToken(req.get('authorization'), req.get('x-api-key') ?? req.get('x-auth-token'));
// Every token is compared even after a match, so the timing does not
// tell which one was presented.
const matched =
presented !== undefined &&
expected.map((token) => constantTimeEquals(Buffer.from(presented, 'utf8'), token)).includes(true);
if (!matched) {
res.setHeader('WWW-Authenticate', 'Bearer realm="schulcloud-mcp"');
res.status(401).json({
jsonrpc: '2.0',
@@ -50,10 +58,16 @@ export function pathSecret(expected: string) {
function extractToken(authorization: string | undefined, apiKey: string | undefined): string | undefined {
if (authorization) {
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
const value = authorization.trim();
const match = /^Bearer\s+(.+)$/i.exec(value);
if (match?.[1]) return match[1].trim();
// claude.ai sends a request header exactly as typed, so a token entered
// without "Bearer " arrives bare — its own docs warn most servers reject
// that. A bare credential is still the whole credential; one with another
// scheme ("Basic …") has a space in it and is not taken for one.
if (value && !/\s/.test(value)) return value;
}
// Some connector UIs only offer a custom header rather than Authorization.
// Connector UIs also offer X-Api-Key and X-Auth-Token instead of Authorization.
return apiKey?.trim() || undefined;
}

View File

@@ -57,11 +57,12 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
res.json({ status: 'ok', sessions: sessions.size, index: services?.store ? 'on' : 'off' });
});
// One token guards both surfaces: the MCP endpoint and the CLI's file/manifest
// API. Splitting them was considered and rejected as unnecessary ceremony for
// a single-user deployment.
// MCP_AUTH_TOKEN opens both surfaces: the MCP endpoint and the CLI's API. The
// connector token opens /mcp alone. claude.ai stores it as a request header,
// and a credential held by a third party should reach the read-only tools,
// not /api, which can replace the Schulcloud token and stream the file mirror.
if (config.authToken) {
app.use(MCP_PATH, bearerAuth(config.authToken));
app.use(MCP_PATH, bearerAuth(config.connectorToken ? [config.authToken, config.connectorToken] : config.authToken));
app.use(API_PATH, bearerAuth(config.authToken));
} else {
console.warn(