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

@@ -20,6 +20,8 @@ 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;
const CONNECTOR_TOKEN = randomBytes(32).toString('hex');
process.env.MCP_CONNECTOR_TOKEN = CONNECTOR_TOKEN;
// 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;
@@ -473,6 +475,38 @@ 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== connector token ==');
// claude.ai sends a request header it stores, so the token in it opens /mcp and
// nothing else: /api can replace the Schulcloud token and stream the mirror.
{
const root = `http://127.0.0.1:${port}`;
const viaHeader = new Client({ name: 'smoke-connector', version: '0' }, { capabilities: {} });
await viaHeader.connect(
new StreamableHTTPClientTransport(new URL(`${root}/mcp`), {
requestInit: { headers: { authorization: `Bearer ${CONNECTOR_TOKEN}` } },
}),
);
const connectorTools = await viaHeader.listTools();
check('the connector token opens /mcp', connectorTools.tools.length === tools.length, `${connectorTools.tools.length} tools`);
await viaHeader.close();
const asApiKey = await fetch(`${root}/mcp`, {
method: 'POST',
headers: { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'x-api-key': CONNECTOR_TOKEN },
body: JSON.stringify({
jsonrpc: '2.0',
id: 1,
method: 'initialize',
params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'smoke-api-key', version: '0' } },
}),
});
check('the connector token works as x-api-key too', asApiKey.ok, `got ${asApiKey.status}`);
await asApiKey.body?.cancel();
const onApi = await fetch(`${root}/api/token`, { headers: { authorization: `Bearer ${CONNECTOR_TOKEN}` } });
check('the connector token is refused on /api', onApi.status === 401, `got ${onApi.status}`);
}
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.