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:
@@ -24,6 +24,13 @@ TSC_JWT_COOKIE=
|
||||
# openssl rand -hex 32
|
||||
MCP_AUTH_TOKEN=
|
||||
|
||||
# claude.ai only: serve MCP at /<this value>/mcp WITHOUT the bearer token,
|
||||
# because its connector dialog cannot send a header. The URL becomes the
|
||||
# credential — see "Connecting Claude" in docs/DEPLOYMENT.md before using it.
|
||||
# At least 32 URL-safe characters; unset = off. Generate one with:
|
||||
# openssl rand -hex 32
|
||||
# MCP_PATH_SECRET=
|
||||
|
||||
# Where a Schulcloud token replaced at runtime (`schulcloud token set`, /token)
|
||||
# is saved, so a restart keeps it. docker-compose.yml sets /data/state; unset =
|
||||
# replacements last until the next restart.
|
||||
|
||||
18
CLAUDE.md
18
CLAUDE.md
@@ -39,8 +39,8 @@ index.
|
||||
read-only with respect to Schulcloud. Run `smoke` after touching `src/core/`,
|
||||
`src/mcp/` or `src/http/` — the unit tests cover only pure functions.
|
||||
|
||||
Run smoke **both ways**: with `DATABASE_URL` set (74 checks, index-backed) and
|
||||
without (72 checks, live-only). The degradation path is a supported mode, not a
|
||||
Run smoke **both ways**: with `DATABASE_URL` set (76 checks, index-backed) and
|
||||
without (74 checks, live-only). The degradation path is a supported mode, not a
|
||||
fallback nobody exercises. Every Schulcloud check fails with 401 when the live
|
||||
session has lapsed — check the container's keepalive log before suspecting code.
|
||||
|
||||
@@ -78,8 +78,9 @@ bin/cli.ts ──HTTP──────────┘ cli/{config,client,sync
|
||||
- `session-token.ts` — the Schulcloud token, replaceable at runtime: checked
|
||||
with `GET /me` (same `userId`), swapped into `config.jwt`, saved to
|
||||
`STATE_DIR`. **Read `config.jwt` at the moment of use; never keep a copy.**
|
||||
- **`http/`** — besides `/mcp` and `/api`: `/token`, a page that PUTs a fresh
|
||||
token to `/api/token`. docs/AUTH.md says why it exists.
|
||||
- **`http/`** — besides `/mcp` and `/api`: the optional `/<secret>/mcp` for
|
||||
claude.ai (`MCP_PATH_SECRET`), and `/token`, a page that PUTs a fresh token to
|
||||
`/api/token`. docs/AUTH.md and docs/DEPLOYMENT.md say why each exists.
|
||||
- **`store/`** — crawl generations, identity diffs, `german` + `pg_trgm` FTS.
|
||||
`Store.open` returns `undefined` when Postgres is down; callers degrade.
|
||||
- **`indexer/`** — crawl → persist → mirror bytes → extract text → index.
|
||||
@@ -128,8 +129,9 @@ user explicitly asking for one and understanding this.
|
||||
**Never log or echo secrets.** `TSC_JWT_COOKIE` grants full read access to the
|
||||
account; `MCP_AUTH_TOKEN` guards the endpoint. Neither belongs in
|
||||
logs, error messages, or tool output. `.env` is git-ignored — keep it that way.
|
||||
One more counts as a secret: a token replaced at runtime, which lives only in
|
||||
`STATE_DIR`, mode 0600.
|
||||
Two more count as secrets: a token replaced at runtime (it lives only in
|
||||
`STATE_DIR`, mode 0600) and, when `MCP_PATH_SECRET` is set, **request paths** —
|
||||
so nothing may log a URL path, and config errors describe the rule, not the value.
|
||||
|
||||
**Live behaviour beats upstream source.** The clones in `vendor/` track `main`
|
||||
and may be ahead of what is deployed. When they disagree with the instance, the
|
||||
@@ -328,8 +330,8 @@ bundle (2.1.272), not its docs:
|
||||
|
||||
## Environment
|
||||
|
||||
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`; docker-compose sets
|
||||
`STATE_DIR`. See `.env.example` for the
|
||||
`.env` holds `TSC_URL`, `TSC_JWT_COOKIE`, `MCP_AUTH_TOKEN`, and optionally
|
||||
`MCP_PATH_SECRET`; docker-compose sets `STATE_DIR`. See `.env.example` for the
|
||||
full set and `docs/AUTH.md` for refreshing the JWT — `schulcloud token set`,
|
||||
no restart. `npm run probe` and `schulcloud token` report the clocks: days until
|
||||
hard expiry and the session budget.
|
||||
|
||||
10
README.md
10
README.md
@@ -115,6 +115,12 @@ copy is the browser's own session token**, so a Schulportal tab left open will
|
||||
auto-logout after ~2 hours and revoke this server's token with it. Copy the
|
||||
token in a private window and close it. See [docs/AUTH.md](docs/AUTH.md).
|
||||
|
||||
**claude.ai reaches it by a secret path, for now.** Its connector dialog takes
|
||||
a URL and no header, so `MCP_PATH_SECRET` serves MCP at `/<secret>/mcp` without
|
||||
the bearer token — never logged, redacted by the Caddy snippet, and a stopgap
|
||||
until the endpoint speaks OAuth. Claude Code and the CLI keep the bearer token.
|
||||
See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
|
||||
|
||||
**Read-only by construction.** Every method on the API client is a `GET`,
|
||||
including `api_get`. The endpoint is internet-facing by necessity (Claude's
|
||||
connectors call it from Anthropic's cloud), so the fact that a leaked token
|
||||
@@ -177,8 +183,8 @@ npm run typecheck
|
||||
```
|
||||
|
||||
`npm run smoke` starts the HTTP server, connects a real MCP client over
|
||||
Streamable HTTP and exercises every tool against the live account — 72 checks (74 with the index)
|
||||
covering the auth gate, the protocol handshake, every content chain, file
|
||||
Streamable HTTP and exercises every tool against the live account — 74 checks (76 with the index)
|
||||
covering the auth gate and the secret path, the protocol handshake, every content chain, file
|
||||
extraction, resources and prompts, token replacement, `api_get`'s guard rails and error handling.
|
||||
|
||||
## Upstream
|
||||
|
||||
@@ -38,9 +38,13 @@ mcp.example.org {
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/schulcloud-mcp.log
|
||||
format json
|
||||
# Request URLs are not secrets here (the token is in a header, not the
|
||||
# path), but the Authorization header must never be written to disk.
|
||||
# Caddy does not log headers by default; do not add them.
|
||||
# With MCP_PATH_SECRET set, a request path *is* a credential: claude.ai
|
||||
# reaches the server at /<secret>/mcp. The filter rewrites that segment
|
||||
# before the entry is written. The Authorization header must never reach
|
||||
# disk either; Caddy does not log headers by default — do not add them.
|
||||
format filter {
|
||||
request>uri regexp ^/[A-Za-z0-9_-]{32,}/mcp /<secret>/mcp
|
||||
wrap json
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
23
docs/AUTH.md
23
docs/AUTH.md
@@ -192,16 +192,29 @@ a shared secret checked in constant time on every `/mcp` request
|
||||
Generate one with `openssl rand -hex 32`. If it is unset the server logs a loud
|
||||
warning and serves unauthenticated — only acceptable bound to localhost.
|
||||
|
||||
Rotating it: change `MCP_AUTH_TOKEN` in `.env`, restart the container, update
|
||||
the connector in Claude. Nothing else stores it.
|
||||
Rotating it: change `MCP_AUTH_TOKEN` in `.env`, recreate the container, and
|
||||
update Claude Code and the CLI (`schulcloud login`). Nothing else stores it.
|
||||
|
||||
### The secret path, for claude.ai
|
||||
|
||||
claude.ai's connector dialog takes a URL and no header, so `MCP_PATH_SECRET`
|
||||
opens a second way in: `/<secret>/mcp`, with no bearer token. The path is the
|
||||
credential there. `http/auth.ts` compares it in constant time and answers a
|
||||
wrong one with the same 404 as any unknown path; `config.ts` insists on at
|
||||
least 32 URL-safe characters and never echoes the value; the server never logs
|
||||
request paths; and `deploy/Caddyfile.snippet` rewrites the segment before an
|
||||
access log entry is written. Rotating it means a new value, a recreated
|
||||
container, and re-adding the connector. It is a stopgap: OAuth is how
|
||||
connectors are meant to authenticate, and it would make the URL a plain
|
||||
address again.
|
||||
|
||||
## Blast radius
|
||||
|
||||
Every path in this server is a `GET`, including the `api_get` escape hatch,
|
||||
which rejects anything not starting with `/api/` and anything carrying a scheme
|
||||
or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` could
|
||||
read this account's Schulcloud data; they could not post, submit, delete, or
|
||||
otherwise act as the user. With `MCP_AUTH_TOKEN` they
|
||||
or host. Someone who obtained both the endpoint URL and `MCP_AUTH_TOKEN` — or
|
||||
the secret MCP path — could read this account's Schulcloud data; they could not
|
||||
post, submit, delete, or otherwise act as the user. With `MCP_AUTH_TOKEN` they
|
||||
could also call `PUT /api/token`, but it accepts only a live token for the same
|
||||
account, so the most it can do is hand the server a session the owner already
|
||||
has. Keep it that way — adding a single write tool would change that property
|
||||
|
||||
@@ -13,14 +13,16 @@ schulcloud CLI ─────┘ └─ Caddy
|
||||
└──▶ schulcloud-thueringen.de
|
||||
```
|
||||
|
||||
Both front ends use the same hostname and the same bearer token. `/mcp` speaks MCP; `/api` serves the
|
||||
Both front ends use the same hostname. `/mcp` speaks MCP; `/api` serves the
|
||||
CLI's manifest, file bytes, re-crawl requests and token replacement; `/token` is
|
||||
a page for pasting a fresh Schulcloud token.
|
||||
|
||||
Claude's custom connectors call the endpoint from Anthropic's cloud
|
||||
(`160.79.104.0/21`), so it must be publicly reachable over real TLS — a
|
||||
localhost tunnel or self-signed cert will not do. The VPS provides the public
|
||||
address; Caddy on the Pi terminates TLS and obtains the certificate.
|
||||
address; Caddy on the Pi terminates TLS and obtains the certificate. **Keep the
|
||||
VPS forwarding raw TCP** rather than terminating TLS itself: then it never sees
|
||||
a request path, which matters once a path carries a secret (below).
|
||||
|
||||
**The Pi must stay up.** More than two hours offline ends the Schulcloud session
|
||||
however long the token has left — a laptop that sleeps overnight loses it every
|
||||
@@ -40,6 +42,7 @@ cd /opt/schulcloud-mcp
|
||||
cp .env.example .env
|
||||
# Fill in TSC_URL and TSC_JWT_COOKIE (see docs/AUTH.md), then:
|
||||
openssl rand -hex 32 # → MCP_AUTH_TOKEN
|
||||
openssl rand -hex 32 # → MCP_PATH_SECRET, only for claude.ai (see "Connecting Claude")
|
||||
|
||||
docker compose up -d --build
|
||||
docker compose logs -f schulcloud-mcp
|
||||
@@ -111,6 +114,9 @@ Two settings in that snippet matter and are easy to miss:
|
||||
connector hangs with no error.
|
||||
- **`read_timeout`/`write_timeout` of 300s** — a `search` call walks every
|
||||
course and can take tens of seconds. Caddy's defaults will cut it off.
|
||||
- **The `format filter` in `log`** — rewrites `/<secret>/mcp` before an access
|
||||
log entry is written. Without it every claude.ai request writes the secret to
|
||||
disk. Verified against Caddy 2.11: the entry reads `"uri":"/<secret>/mcp"`.
|
||||
|
||||
## Ports and DNS
|
||||
|
||||
@@ -128,6 +134,10 @@ curl -s https://mcp.example.org/healthz
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/mcp \
|
||||
-H 'content-type: application/json' -d '{}'
|
||||
# 401 ← the bearer check is live
|
||||
|
||||
curl -s -o /dev/null -w '%{http_code}\n' -X POST https://mcp.example.org/$(openssl rand -hex 32)/mcp \
|
||||
-H 'content-type: application/json' -d '{}'
|
||||
# 404 ← a wrong path secret looks like any unknown path
|
||||
```
|
||||
|
||||
If `/healthz` answers but `/mcp` returns 401 with a correct token, check that
|
||||
@@ -136,16 +146,45 @@ newline from a copy-paste.
|
||||
|
||||
## Connecting Claude
|
||||
|
||||
1. claude.ai → **Settings → Connectors → Add custom connector**.
|
||||
2. URL: `https://mcp.example.org/mcp`
|
||||
3. Under **Advanced settings**, add the bearer token as an authorization
|
||||
header. If your organisation has no header-auth field, the server also
|
||||
accepts the token as `X-Api-Key`.
|
||||
4. Enable the connector in a conversation via **+ → Add connectors**.
|
||||
### claude.ai — a secret path, for now
|
||||
|
||||
Ask *"which courses am I in?"* as a first check — that exercises auth, the
|
||||
claude.ai's *Add custom connector* dialog takes a name and a URL. Sending a
|
||||
bearer token needs its "Request headers" section, a beta most accounts do not
|
||||
have, and the proper answer — OAuth — is not built yet. Until it is, the
|
||||
server can serve MCP at a path that is itself the secret:
|
||||
|
||||
1. Put `MCP_PATH_SECRET=<openssl rand -hex 32>` in `.env` and recreate the
|
||||
container: `docker compose up -d --force-recreate schulcloud-mcp`. The
|
||||
startup line then says `(plus secret MCP path)` — never the secret itself.
|
||||
2. claude.ai → **Customize → Connectors → Add custom connector**. Name it, and
|
||||
give the URL `https://mcp.example.org/<secret>/mcp`. No sign-in.
|
||||
3. Enable it in a conversation via **+ → Connectors**.
|
||||
|
||||
Ask *"which courses am I in?"* as a first check — that exercises the path, the
|
||||
Schulcloud token and the API in one call.
|
||||
|
||||
**Know what this trades away.** The URL is now the credential, and Anthropic's
|
||||
connector documentation calls credentials in URLs a security vulnerability,
|
||||
because URLs end up in logs. This deployment keeps it out of its own: the
|
||||
server never logs request paths, the Caddy snippet rewrites the segment to
|
||||
`<secret>` in the access log, and a VPS forwarding raw TCP never sees it.
|
||||
Caddy's *error* log can still name the path if the container is down while
|
||||
claude.ai calls, and claude.ai stores the URL in its connector settings. There
|
||||
is no revocation short of a new secret: change `MCP_PATH_SECRET`, recreate the
|
||||
container, and add the connector again. Anyone holding the URL can read — never
|
||||
change — the account. A wrong secret answers 404, like any unknown path.
|
||||
|
||||
### Claude Code and the CLI — the bearer token
|
||||
|
||||
Both can send a header, so they keep using `MCP_AUTH_TOKEN` on the plain `/mcp`
|
||||
and `/api`:
|
||||
|
||||
```bash
|
||||
claude mcp add --transport http --scope user schulcloud https://mcp.example.org/mcp \
|
||||
--header "Authorization: Bearer <MCP_AUTH_TOKEN>"
|
||||
schulcloud login --server https://mcp.example.org --token <MCP_AUTH_TOKEN>
|
||||
```
|
||||
|
||||
## Replacing the Schulcloud token
|
||||
|
||||
The token lasts 30 days at most and can only come from a browser login (see
|
||||
|
||||
@@ -107,9 +107,9 @@ node dist/bin/cli.js sync
|
||||
## Run the test suites
|
||||
|
||||
```bash
|
||||
npm test # 174 offline tests
|
||||
npm test # 178 offline tests
|
||||
npm run smoke # end-to-end against the live instance, live-only mode
|
||||
DATABASE_URL=… npm run smoke # end-to-end with the index (74 checks)
|
||||
DATABASE_URL=… npm run smoke # end-to-end with the index (76 checks)
|
||||
```
|
||||
|
||||
Store tests need a database and skip without one:
|
||||
|
||||
@@ -217,7 +217,7 @@ be pointed at the instance in between:
|
||||
|
||||
```bash
|
||||
eval "$(./scripts/mcp-env.sh)" # as the demo student
|
||||
cd .. && npm run smoke # 74 checks against the local instance
|
||||
cd .. && npm run smoke # 76 checks against the local instance
|
||||
```
|
||||
|
||||
`mcp-env.sh` points the index at its own database, `schulcloud_local`, and the
|
||||
|
||||
@@ -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}`);
|
||||
|
||||
@@ -70,7 +70,7 @@ async function main(): Promise<void> {
|
||||
const token = services.session.status();
|
||||
console.log(
|
||||
`[schulcloud-mcp] listening on ${config.bindHost}:${config.port} — instance ${config.baseUrl}, ` +
|
||||
`auth ${config.authToken ? 'enabled' : 'DISABLED'}, ` +
|
||||
`auth ${config.authToken ? 'enabled' : 'DISABLED'}${config.mcpPathSecret ? ' (plus secret MCP path)' : ''}, ` +
|
||||
`token from ${token.source}${token.daysLeft === undefined ? '' : `, ${token.daysLeft} day(s) left`}` +
|
||||
`${token.persistent ? '' : ' (replacements not saved: STATE_DIR unset)'}, ` +
|
||||
`keepalive ${keepalive ? `every ${Math.round(config.keepaliveIntervalMs / 60_000)}min` : 'off'}, ` +
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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