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:
@@ -20,6 +20,12 @@ export interface Config {
|
||||
jwt: string;
|
||||
/** Shared secret callers must present to this MCP server. Unused in stdio mode. */
|
||||
authToken: string | undefined;
|
||||
/**
|
||||
* Serves MCP at `/<secret>/mcp` without a bearer token, for clients that can
|
||||
* send none — claude.ai's connector dialog takes only a URL. The path is then
|
||||
* the credential, so it must never be logged.
|
||||
*/
|
||||
mcpPathSecret: string | undefined;
|
||||
/** Where state that must survive a restart is kept: a replaced session token. Unset = memory only. */
|
||||
stateDir: string | undefined;
|
||||
port: number;
|
||||
@@ -73,6 +79,20 @@ function int(name: string, fallback: number): number {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
/** A URL-safe secret of at least 32 characters, or undefined when unset. */
|
||||
function pathSecret(name: string): string | undefined {
|
||||
const value = process.env[name]?.trim();
|
||||
if (!value) return undefined;
|
||||
// The value is a credential: the error states the rule and never echoes it.
|
||||
if (!/^[A-Za-z0-9_-]{32,}$/.test(value)) {
|
||||
throw new Error(
|
||||
`Environment variable ${name} must be at least 32 characters of A-Z, a-z, 0-9, "-" or "_". ` +
|
||||
'Generate one with: openssl rand -hex 32',
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Like `int`, but 0 is meaningful (it disables the feature) rather than invalid. */
|
||||
function intAllowingZero(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
@@ -89,6 +109,7 @@ export function loadConfig(): Config {
|
||||
baseUrl: required('TSC_URL').replace(/\/+$/, ''),
|
||||
jwt: required('TSC_JWT_COOKIE'),
|
||||
authToken: process.env.MCP_AUTH_TOKEN?.trim() || undefined,
|
||||
mcpPathSecret: pathSecret('MCP_PATH_SECRET'),
|
||||
stateDir: process.env.STATE_DIR?.trim() ? resolve(process.env.STATE_DIR.trim()) : undefined,
|
||||
port: int('PORT', 8080),
|
||||
bindHost: process.env.BIND_HOST?.trim() || '0.0.0.0',
|
||||
|
||||
Reference in New Issue
Block a user