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:
@@ -1,6 +1,6 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { bearerAuth } from '../src/http/auth.ts';
|
||||
import { bearerAuth, pathSecret } from '../src/http/auth.ts';
|
||||
|
||||
function run(headers: Record<string, string>): { status?: number; passed: boolean } {
|
||||
const middleware = bearerAuth('correct-horse-battery-staple');
|
||||
@@ -53,3 +53,39 @@ describe('bearerAuth', () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('pathSecret', () => {
|
||||
const secret = 'a'.repeat(40) + 'B-_9';
|
||||
|
||||
function visit(presented: unknown): { status?: number; passed: boolean } {
|
||||
const middleware = pathSecret(secret);
|
||||
let status: number | undefined;
|
||||
let passed = false;
|
||||
const req = { params: { secret: presented } } as never;
|
||||
const res = {
|
||||
status(code: number) {
|
||||
status = code;
|
||||
return this;
|
||||
},
|
||||
json() {
|
||||
return this;
|
||||
},
|
||||
} as never;
|
||||
middleware(req, res, () => {
|
||||
passed = true;
|
||||
});
|
||||
return { status, passed };
|
||||
}
|
||||
|
||||
it('lets the exact secret through', () => {
|
||||
assert.equal(visit(secret).passed, true);
|
||||
});
|
||||
|
||||
it('answers anything else like an unknown path, not like a refused login', () => {
|
||||
for (const presented of [undefined, '', 'mcp', secret.slice(0, -1), `${secret}x`, secret.toUpperCase()]) {
|
||||
const result = visit(presented);
|
||||
assert.equal(result.passed, false, `should reject ${JSON.stringify(presented)}`);
|
||||
assert.equal(result.status, 404);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -35,7 +35,27 @@ describe('loadConfig', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('loadConfig: state directory', () => {
|
||||
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';
|
||||
|
||||
Reference in New Issue
Block a user