Index-backed search, /api surfaces, image-only PDF detection
search now queries the Postgres index and states its freshness in every result, with fresh=true bypassing it for a live crawl — the agent can always get current data rather than being quietly misled by a stale index. Adds refresh_index (per-course by default; a full crawl is ~270 requests), what_changed (generation diff — the API has no changed-since filter of any kind), and index_status. /api gives the CLI its backend behind the same bearer token as /mcp: GET /manifest (cursor + per-file status), GET /files/:id (served from the mirror with Range support, falling back to a live proxy for files too large to mirror), GET /status, POST /refresh. Bytes go over plain HTTP rather than MCP because base64 in JSON-RPC costs a third more and buffers whole files. An unresolvable manifest cursor returns 409 rather than silently meaning "everything is new", so a client cannot be tricked into a full re-download. Verified end to end against the live instance and a real Postgres: crawl -> index -> German FTS -> manifest -> ranged download, with 401 on missing token, 400 on a malformed id, and 429 on a too-soon refresh. Two findings worth recording. The build silently omitted the .sql migrations from dist, which the store's graceful degradation turned into "running without the index" rather than a crash — now copied by a build step. And 3 of 4 sampled course PDFs have no embedded fonts at all: they are scans, so extraction legitimately yields nothing. That is now detected and reported as image-only with OCR named as the missing piece, instead of an indistinguishable "0 characters". It revises the roadmap's "OCR not needed" note, which held for reading images but not for indexing them. 49 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,8 @@ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/
|
||||
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
||||
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';
|
||||
|
||||
/**
|
||||
@@ -17,6 +19,8 @@ import { bearerAuth } from './auth.ts';
|
||||
*/
|
||||
|
||||
const MCP_PATH = '/mcp';
|
||||
/** The CLI's surface: file bytes, sync manifest, on-demand re-crawl. */
|
||||
const API_PATH = '/api';
|
||||
/** Sessions are dropped after this long without traffic, in case DELETE never arrives. */
|
||||
const SESSION_IDLE_MS = 30 * 60 * 1000;
|
||||
|
||||
@@ -26,7 +30,7 @@ interface Session {
|
||||
lastSeen: number;
|
||||
}
|
||||
|
||||
export function createHttpApp(config: Config): express.Express {
|
||||
export function createHttpApp(config: Config, services?: Services): express.Express {
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
// Caddy sits in front and terminates TLS; trust its forwarding headers so
|
||||
@@ -49,11 +53,15 @@ export function createHttpApp(config: Config): express.Express {
|
||||
// Liveness probe for Docker/Caddy. Deliberately before auth and free of any
|
||||
// detail about the instance or the account.
|
||||
app.get('/healthz', (_req, res) => {
|
||||
res.json({ status: 'ok', sessions: sessions.size });
|
||||
res.json({ status: 'ok', sessions: sessions.size, index: services?.store ? 'on' : 'off' });
|
||||
});
|
||||
|
||||
// One token guards both surfaces: the MCP endpoint and the CLI's file/manifest
|
||||
// API. Splitting them was considered and rejected as unnecessary ceremony for
|
||||
// a single-user deployment.
|
||||
if (config.authToken) {
|
||||
app.use(MCP_PATH, bearerAuth(config.authToken));
|
||||
app.use(API_PATH, bearerAuth(config.authToken));
|
||||
} else {
|
||||
console.warn(
|
||||
'[schulcloud-mcp] MCP_AUTH_TOKEN is not set — the endpoint is UNAUTHENTICATED. ' +
|
||||
@@ -61,6 +69,10 @@ export function createHttpApp(config: Config): express.Express {
|
||||
);
|
||||
}
|
||||
|
||||
if (services) {
|
||||
app.use(API_PATH, createApiRouter(services));
|
||||
}
|
||||
|
||||
app.use(MCP_PATH, express.json({ limit: '4mb' }));
|
||||
|
||||
app.post(MCP_PATH, async (req: Request, res: Response) => {
|
||||
@@ -83,7 +95,7 @@ export function createHttpApp(config: Config): express.Express {
|
||||
return;
|
||||
}
|
||||
|
||||
const { server } = createServer(config);
|
||||
const { server } = createServer(config, services);
|
||||
const transport = new StreamableHTTPServerTransport({
|
||||
sessionIdGenerator: () => crypto.randomUUID(),
|
||||
onsessioninitialized: (id) => {
|
||||
|
||||
Reference in New Issue
Block a user