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>
177 lines
6.9 KiB
TypeScript
177 lines
6.9 KiB
TypeScript
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
import { z } from 'zod';
|
|
import type { ServerContext } from '../../context.ts';
|
|
import { formatDate, heading, joinSections } from '../../core/text.ts';
|
|
import { failure, text, toToolError } from './result.ts';
|
|
|
|
const READ_ONLY = { readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: true };
|
|
|
|
export function registerIndexTools(server: McpServer, context: ServerContext): void {
|
|
server.registerTool(
|
|
'refresh_index',
|
|
{
|
|
title: 'Re-crawl Schulcloud',
|
|
description:
|
|
'Re-reads Schulcloud and updates the local index, so search and what_changed see the newest state. ' +
|
|
'Pass a courseId when you know which course changed — that costs a handful of requests, whereas a ' +
|
|
'full re-crawl reads every course and takes up to a minute. Use it when the user says they just ' +
|
|
'uploaded or were given something and search cannot find it yet.',
|
|
inputSchema: {
|
|
courseId: z
|
|
.string()
|
|
.optional()
|
|
.describe('Re-crawl only this course. Omit to re-crawl everything (slow).'),
|
|
force: z
|
|
.boolean()
|
|
.default(false)
|
|
.describe('Override the minimum interval between re-crawls. Use sparingly.'),
|
|
},
|
|
// Not read-only: this writes to the local index. It still cannot change
|
|
// anything in Schulcloud — every upstream call it makes is a GET.
|
|
annotations: { readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: true },
|
|
},
|
|
async ({ courseId, force }) => {
|
|
if (!context.indexer) return failure(indexUnavailable('refresh_index'));
|
|
try {
|
|
const result = await context.indexer.refresh(courseId ?? 'full', { force });
|
|
return text(
|
|
joinSections([
|
|
heading(2, result.joined ? 'Joined a re-crawl already in progress' : 'Re-crawl complete'),
|
|
[
|
|
`- Scope: ${result.scope === 'full' ? 'all courses' : `course ${result.scope}`}`,
|
|
`- Generation: ${result.crawlId}`,
|
|
`- Courses: ${result.courses}, files: ${result.files}`,
|
|
`- Newly mirrored: ${result.mirrored}, text extracted: ${result.extracted}, skipped: ${result.skipped}`,
|
|
`- Took ${(result.durationMs / 1000).toFixed(1)}s`,
|
|
result.failures.length > 0
|
|
? `- Could not read ${result.failures.length} course(s): ${result.failures.map((f) => f.courseId).join(', ')}`
|
|
: undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n'),
|
|
]),
|
|
);
|
|
} catch (error) {
|
|
return toToolError(error, 'refresh the index');
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
'what_changed',
|
|
{
|
|
title: 'What changed recently',
|
|
description:
|
|
'Lists boards, cards, files, lessons and tasks that appeared, changed or disappeared since a point in ' +
|
|
'time. The Schulcloud API has no "changed since" filter of any kind, so this compares stored crawls — ' +
|
|
'meaning it can only see back as far as the index goes. This is the tool for "what is new this week?".',
|
|
inputSchema: {
|
|
since: z
|
|
.string()
|
|
.describe('An ISO date/time, or a generation id from refresh_index. e.g. "2026-09-10".'),
|
|
kinds: z
|
|
.array(z.enum(['course', 'board', 'lesson', 'task', 'file']))
|
|
.optional()
|
|
.describe('Restrict to certain kinds of thing. Omit for all.'),
|
|
limit: z.number().int().min(1).max(200).default(50).describe('Maximum entries per section.'),
|
|
},
|
|
annotations: READ_ONLY,
|
|
},
|
|
async ({ since, kinds, limit }) => {
|
|
if (!context.store) return failure(indexUnavailable('what_changed'));
|
|
try {
|
|
const from = await context.store.resolveCursor(since);
|
|
const to = await context.store.latestCrawlId();
|
|
if (to === undefined) {
|
|
return failure('The index is empty — run refresh_index first.');
|
|
}
|
|
if (from === undefined) {
|
|
return failure(
|
|
`No crawl exists at or before "${since}". The index only goes back as far as its oldest ` +
|
|
`stored crawl; try a more recent date.`,
|
|
);
|
|
}
|
|
if (from === to) {
|
|
return text(`Nothing has changed since ${since} — the index has not been re-crawled since then.`);
|
|
}
|
|
|
|
const diff = await context.store.diff(from, to);
|
|
const wanted = kinds ? new Set(kinds) : undefined;
|
|
const keep = <T extends { kind: string }>(items: T[]) =>
|
|
(wanted ? items.filter((item) => wanted.has(item.kind as never)) : items).slice(0, limit);
|
|
|
|
const added = keep(diff.added);
|
|
const changed = keep(diff.changed);
|
|
const removed = keep(diff.removed);
|
|
|
|
if (added.length + changed.length + removed.length === 0) {
|
|
return text(`Nothing matching changed between generation ${from} and ${to}.`);
|
|
}
|
|
|
|
return text(
|
|
joinSections([
|
|
heading(2, `Changes since ${since} (generations ${from} → ${to})`),
|
|
section('New', added.map((node) => `- ${node.kind}: **${node.title}** — ${node.path} (\`${node.nodeId}\`)`)),
|
|
section('Changed', changed.map((node) => `- ${node.kind}: **${node.title}** — ${node.path} (\`${node.nodeId}\`)`)),
|
|
section('Gone', removed.map((node) => `- ${node.kind}: ${node.title} — ${node.path}`)),
|
|
]),
|
|
);
|
|
} catch (error) {
|
|
return toToolError(error, `compare changes since ${since}`);
|
|
}
|
|
},
|
|
);
|
|
|
|
server.registerTool(
|
|
'index_status',
|
|
{
|
|
title: 'Index status',
|
|
description:
|
|
'How fresh the local index is: when it last crawled, how much it holds, and whether a crawl is ' +
|
|
'running. Check this when search results look out of date before assuming something is missing.',
|
|
inputSchema: {},
|
|
annotations: READ_ONLY,
|
|
},
|
|
async () => {
|
|
if (!context.store) return failure(indexUnavailable('index_status'));
|
|
try {
|
|
const stats = await context.store.stats();
|
|
const status = context.indexer?.status();
|
|
if (stats.crawlId === undefined) {
|
|
return text('The index is empty. Run refresh_index to populate it.');
|
|
}
|
|
const age = stats.crawledAt ? Date.now() - new Date(stats.crawledAt).getTime() : undefined;
|
|
return text(
|
|
joinSections([
|
|
heading(2, 'Index status'),
|
|
[
|
|
`- Generation ${stats.crawlId}, crawled ${formatDate(stats.crawledAt)}` +
|
|
(age !== undefined ? ` (${Math.round(age / 60_000)} min ago)` : ''),
|
|
`- ${stats.nodes} indexed items, ${stats.files} files`,
|
|
`- ${stats.extracted} files with extracted text, ${stats.mirrored} mirrored locally`,
|
|
status?.running ? `- **A re-crawl is running now** (scope: ${status.scope})` : undefined,
|
|
status?.lastError ? `- Last error: ${status.lastError}` : undefined,
|
|
]
|
|
.filter(Boolean)
|
|
.join('\n'),
|
|
]),
|
|
);
|
|
} catch (error) {
|
|
return toToolError(error, 'read index status');
|
|
}
|
|
},
|
|
);
|
|
}
|
|
|
|
function section(title: string, lines: string[]): string | undefined {
|
|
if (lines.length === 0) return undefined;
|
|
return joinSections([heading(3, `${title} (${lines.length})`), lines.join('\n')]);
|
|
}
|
|
|
|
function indexUnavailable(tool: string): string {
|
|
return (
|
|
`${tool} needs the local index, which is not configured on this server ` +
|
|
`(no DATABASE_URL). Search still works by crawling live on each call.`
|
|
);
|
|
}
|