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

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