Files
Schulcloud-MCP/src/cli/client.ts
MechaCat02 bed3923902 Browse the file manager ("Dateien") as a filesystem
Many teachers never use topics or boards; their material sits in the
course's file area, and the tools answered "0 files" for courses holding
dozens of worksheets — 21 of 26 courses on the live account. Persönliche,
Kurs-, Team- and Geteilte Dateien live in the legacy file store, not in
files-storage, and its service is not in the public ingress. The only way in
is the legacy client: HTML listings, and GET /files/signedurl for a
pre-signed download.

core/legacy-files.ts turns that into one path tree — /my, /courses/<course>,
/teams/<team>, /shared — resolving names that contain "/", ids anywhere in a
path, and wrong or ambiguous names with a message saying what is there. A
listing that does not parse throws; it never reads as an empty folder.

Some of the legacy client's GET routes write (GET /files/share/ mints a
share token), so getFileManagerPage allows only the listing routes, by
pattern. Signed URLs are fetched with no credentials and must be https.

- MCP: fs_list, fs_tree, fs_find and fs_read; get_course lists course files.
- CLI: schulcloud fs ls, tree, find and get, recursive and resumable.
- API: /api/fs/list, tree, find and file.
- Index: the crawl walks the file manager (INDEX_FILE_MANAGER, on by
  default), so search covers the text inside those files and sync mirrors
  them under <course>/Kurs-Dateien.

The local instance gains a fixture for all four areas. It needed a loopback,
so signed URLs open from the host, and a pre-created bucket, since MinIO
does not implement PutBucketCors.

135 tests. Smoke 55/55 live; 57/57 and 55/55 on the local instance.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:19:16 +02:00

152 lines
5.0 KiB
TypeScript

import type { CliConfig } from './config.ts';
/**
* Talks to the schulcloud-mcp server's /api surface.
*
* Deliberately the only thing in the CLI that knows a network exists, and it
* never touches Schulcloud directly — the Pi holds that credential.
*/
export interface ManifestEntry {
fileId: string;
name: string;
path: string;
size: number;
mimeType: string;
courseId: string | null;
courseTitle: string;
status: 'added' | 'unchanged' | 'removed';
}
export interface Manifest {
crawlId: number;
cursor: string;
crawledAt?: string;
count: number;
entries: ManifestEntry[];
}
/** One entry of a file-manager tree or search, as /api/fs returns it. */
export interface FsEntry {
type: 'directory' | 'file';
path: string;
parentPath: string;
depth: number;
id: string;
name: string;
size?: number;
mimeType?: string | null;
blocked?: boolean;
}
export interface FsListing {
path: string;
kind: 'directory' | 'file';
area?: string | null;
directories?: { id: string; name: string; path: string }[];
files?: { id: string; name: string; path: string; size: number; mimeType?: string; blocked: boolean }[];
file?: { id: string; name: string; size: number; mimeType?: string; blocked: boolean };
}
export interface FsWalk {
path: string;
kind: 'directory' | 'file';
entries?: FsEntry[];
matches?: FsEntry[];
file?: FsListing['file'];
visited?: number;
truncated?: boolean;
failures?: { path: string; reason: string }[];
}
export class ApiError extends Error {
readonly status: number;
constructor(status: number, message: string) {
super(message);
this.status = status;
this.name = 'ApiError';
}
}
export class ApiClient {
private readonly config: CliConfig;
constructor(config: CliConfig) {
this.config = config;
}
private async request(path: string, init: RequestInit = {}): Promise<Response> {
const response = await fetch(`${this.config.server}${path}`, {
...init,
headers: { ...(init.headers ?? {}), Authorization: `Bearer ${this.config.token}` },
});
if (!response.ok) {
let detail = '';
try {
const body = (await response.json()) as { message?: string; error?: string };
detail = body.message ?? body.error ?? '';
} catch {
// Non-JSON error bodies are not worth surfacing verbatim.
}
throw new ApiError(response.status, describe(response.status, detail, this.config.server));
}
return response;
}
async status(): Promise<Record<string, unknown>> {
return (await (await this.request('/api/status')).json()) as Record<string, unknown>;
}
async manifest(since?: string): Promise<Manifest> {
const query = since ? `?since=${encodeURIComponent(since)}` : '';
return (await (await this.request(`/api/manifest${query}`)).json()) as Manifest;
}
async refresh(courseId?: string, force = false): Promise<Record<string, unknown>> {
const response = await this.request('/api/refresh', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ courseId, force }),
});
return (await response.json()) as Record<string, unknown>;
}
/** Streams one file's bytes. */
async file(fileId: string): Promise<Response> {
return this.request(`/api/files/${encodeURIComponent(fileId)}`);
}
// --- the file manager ------------------------------------------------------
async fsList(path: string): Promise<FsListing> {
return (await (await this.request(`/api/fs/list?${new URLSearchParams({ path })}`)).json()) as FsListing;
}
async fsTree(path: string, depth: number, maxFolders: number): Promise<FsWalk> {
const query = new URLSearchParams({ path, depth: String(depth), maxFolders: String(maxFolders) });
return (await (await this.request(`/api/fs/tree?${query}`)).json()) as FsWalk;
}
async fsFind(name: string, path: string, type: string, maxFolders: number): Promise<FsWalk> {
const query = new URLSearchParams({ name, path, type, maxFolders: String(maxFolders) });
return (await (await this.request(`/api/fs/find?${query}`)).json()) as FsWalk;
}
/** Streams one file-manager file's bytes, by path or by id. */
async fsFile(target: { path: string } | { id: string; name: string }): Promise<Response> {
const query = 'path' in target ? new URLSearchParams({ path: target.path }) : new URLSearchParams(target);
return this.request(`/api/fs/file?${query}`);
}
}
function describe(status: number, detail: string, server: string): string {
if (status === 401) return `Unauthorized — the token is wrong or expired. Re-run: schulcloud login --server ${server} --token <token>`;
if (status === 503) return 'The server is running without an index, so this command is unavailable. Set DATABASE_URL on the server.';
if (status === 409) return detail || 'The sync cursor is unknown to the server. Run a full sync with --full.';
if (status === 429) return detail || 'Refreshed too recently — wait a moment, or pass --force.';
// The file manager's own errors already say what was not found and what is there.
if ((status === 400 || status === 404 || status === 422) && detail) return detail;
return detail ? `HTTP ${status}: ${detail}` : `HTTP ${status}`;
}