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:
@@ -28,6 +28,26 @@ export function bearerAuth(expected: string) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate for `/:secret/mcp`, the header-free way in.
|
||||
*
|
||||
* A wrong secret answers exactly like any other unknown path, so guessing
|
||||
* learns nothing — not even that the route exists. The comparison is
|
||||
* constant-time for the same reason as the bearer check's.
|
||||
*/
|
||||
export function pathSecret(expected: string) {
|
||||
const expectedBytes = Buffer.from(expected, 'utf8');
|
||||
|
||||
return function checkPathSecret(req: Request, res: Response, next: NextFunction): void {
|
||||
const presented = req.params.secret;
|
||||
if (typeof presented !== 'string' || !constantTimeEquals(Buffer.from(presented, 'utf8'), expectedBytes)) {
|
||||
res.status(404).json({ error: 'not_found' });
|
||||
return;
|
||||
}
|
||||
next();
|
||||
};
|
||||
}
|
||||
|
||||
function extractToken(authorization: string | undefined, apiKey: string | undefined): string | undefined {
|
||||
if (authorization) {
|
||||
const match = /^Bearer\s+(.+)$/i.exec(authorization.trim());
|
||||
|
||||
@@ -6,7 +6,7 @@ import type { Config } from '../config.ts';
|
||||
import { createServer } from '../mcp/server.ts';
|
||||
import type { Services } from '../services.ts';
|
||||
import { createApiRouter } from './api.ts';
|
||||
import { bearerAuth } from './auth.ts';
|
||||
import { bearerAuth, pathSecret } from './auth.ts';
|
||||
import { tokenPage, tokenScript } from './token-page.ts';
|
||||
|
||||
/**
|
||||
@@ -78,9 +78,7 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
|
||||
app.get('/token.js', tokenScript);
|
||||
}
|
||||
|
||||
app.use(MCP_PATH, express.json({ limit: '4mb' }));
|
||||
|
||||
app.post(MCP_PATH, async (req: Request, res: Response) => {
|
||||
const handlePost = async (req: Request, res: Response): Promise<void> => {
|
||||
const sessionId = req.get('mcp-session-id');
|
||||
|
||||
try {
|
||||
@@ -129,7 +127,9 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
|
||||
console.error('[schulcloud-mcp] POST failed:', error);
|
||||
if (!res.headersSent) res.status(500).json(rpcError(-32603, 'Internal server error'));
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
app.post(MCP_PATH, express.json({ limit: '4mb' }), handlePost);
|
||||
|
||||
// GET opens the server→client SSE stream; DELETE ends the session.
|
||||
const bySession = async (req: Request, res: Response): Promise<void> => {
|
||||
@@ -151,6 +151,19 @@ export function createHttpApp(config: Config, services?: Services): express.Expr
|
||||
app.get(MCP_PATH, bySession);
|
||||
app.delete(MCP_PATH, bySession);
|
||||
|
||||
// The same endpoint without a bearer token, for clients that cannot send one:
|
||||
// claude.ai's connector dialog takes only a URL. The path is the credential
|
||||
// here, so nothing in this server logs request paths — keep it that way — and
|
||||
// the Caddy snippet redacts it from the access log. A stopgap until the
|
||||
// endpoint speaks OAuth, which is what connectors are meant to use.
|
||||
if (config.mcpPathSecret) {
|
||||
const secretMcpPath = '/:secret/mcp';
|
||||
const gate = pathSecret(config.mcpPathSecret);
|
||||
app.post(secretMcpPath, gate, express.json({ limit: '4mb' }), handlePost);
|
||||
app.get(secretMcpPath, gate, bySession);
|
||||
app.delete(secretMcpPath, gate, bySession);
|
||||
}
|
||||
|
||||
app.use((_req, res) => res.status(404).json({ error: 'not_found' }));
|
||||
|
||||
return app;
|
||||
|
||||
Reference in New Issue
Block a user