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:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user