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>
102 lines
3.6 KiB
TypeScript
102 lines
3.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import { afterEach, describe, it } from 'node:test';
|
|
import { loadConfig } from '../src/config.ts';
|
|
|
|
const SAVED = { ...process.env };
|
|
afterEach(() => {
|
|
process.env = { ...SAVED };
|
|
});
|
|
|
|
describe('loadConfig', () => {
|
|
it('requires the instance URL and token', () => {
|
|
delete process.env.TSC_URL;
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
assert.throws(() => loadConfig(), /TSC_URL/);
|
|
});
|
|
|
|
it('strips trailing slashes so paths concatenate cleanly', () => {
|
|
process.env.TSC_URL = 'https://example.org///';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
assert.equal(loadConfig().baseUrl, 'https://example.org');
|
|
});
|
|
|
|
it('rejects a non-numeric port rather than silently defaulting', () => {
|
|
process.env.TSC_URL = 'https://example.org';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
process.env.PORT = 'not-a-number';
|
|
assert.throws(() => loadConfig(), /PORT/);
|
|
});
|
|
|
|
it('treats a blank auth token as absent', () => {
|
|
process.env.TSC_URL = 'https://example.org';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
process.env.MCP_AUTH_TOKEN = ' ';
|
|
assert.equal(loadConfig().authToken, undefined);
|
|
});
|
|
});
|
|
|
|
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';
|
|
process.env.STATE_DIR = 'tmp/state';
|
|
assert.match(loadConfig().stateDir ?? '', /^\/.*\/tmp\/state$/);
|
|
});
|
|
});
|
|
|
|
describe('loadConfig: connector token', () => {
|
|
const connector = 'connector-0123456789abcdef0123456789';
|
|
|
|
it('is off by default, and accepted alongside the main token', () => {
|
|
process.env.TSC_URL = 'https://example.org';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
process.env.MCP_AUTH_TOKEN = 'main-token';
|
|
assert.equal(loadConfig().connectorToken, undefined);
|
|
process.env.MCP_CONNECTOR_TOKEN = connector;
|
|
assert.equal(loadConfig().connectorToken, connector);
|
|
});
|
|
|
|
it('refuses a short or spaced token without echoing it', () => {
|
|
process.env.TSC_URL = 'https://example.org';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
process.env.MCP_AUTH_TOKEN = 'main-token';
|
|
for (const token of ['too-short', 'long enough but with spaces in it 0123']) {
|
|
process.env.MCP_CONNECTOR_TOKEN = token;
|
|
assert.throws(
|
|
() => loadConfig(),
|
|
(error: Error) => /MCP_CONNECTOR_TOKEN/.test(error.message) && !error.message.includes(token),
|
|
);
|
|
}
|
|
});
|
|
|
|
it('refuses to leave /api open, or to be the main token under another name', () => {
|
|
process.env.TSC_URL = 'https://example.org';
|
|
process.env.TSC_JWT_COOKIE = 'x';
|
|
process.env.MCP_CONNECTOR_TOKEN = connector;
|
|
delete process.env.MCP_AUTH_TOKEN;
|
|
assert.throws(() => loadConfig(), /needs MCP_AUTH_TOKEN/);
|
|
process.env.MCP_AUTH_TOKEN = connector;
|
|
assert.throws(() => loadConfig(), /must differ from MCP_AUTH_TOKEN/);
|
|
});
|
|
});
|