A token lasts 30 days and only a browser login yields one — the account is federated, so the server cannot mint it. Replacing it meant editing .env and recreating the container, every month. `schulcloud token set` (a hidden prompt, or piped input) and a /token page both send it to PUT /api/token. The server checks it with Schulcloud first — well-formed, unexpired, still logged in, the same account — then swaps it into the config every request reads, restarts the keepalive and saves it in STATE_DIR, a new volume, with mode 0600. At startup the newer of the saved token and TSC_JWT_COOKIE wins, unless they belong to different accounts. A refused paste changes nothing, and the token is never logged. The keepalive's pings carry a generation, so a 401 for the old token that arrives after a swap cannot stop the new cycle. `schulcloud token`, whoami and the log report the expiry and warn a week ahead. Found on the way: a host that is off for more than two hours loses the session however long the token has left — this machine lost it overnight — which is what the always-on Pi is for. 174 tests. Smoke 72/72 on the local instance, and a real swap verified end to end there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
77 lines
3.0 KiB
TypeScript
77 lines
3.0 KiB
TypeScript
import { ErrorCode, type CallToolResult } from '@modelcontextprotocol/sdk/types.js';
|
|
import { SchulcloudApiError } from '../../core/client.ts';
|
|
|
|
/** The spec's "resource not found" code; the SDK's `ErrorCode` has no name for it. */
|
|
const RESOURCE_NOT_FOUND = -32002;
|
|
|
|
/**
|
|
* An error the SDK sends as-is: this code, exactly this message.
|
|
*
|
|
* Not McpError, whose constructor prefixes "MCP error <code>:" to the message
|
|
* — the receiving client prefixes it again, and the person reading it gets both.
|
|
*/
|
|
export class ProtocolError extends Error {
|
|
readonly code: number;
|
|
|
|
constructor(code: number, message: string) {
|
|
super(message);
|
|
this.name = 'ProtocolError';
|
|
this.code = code;
|
|
}
|
|
}
|
|
|
|
export function text(body: string): CallToolResult {
|
|
return { content: [{ type: 'text', text: body }] };
|
|
}
|
|
|
|
export function failure(body: string): CallToolResult {
|
|
return { content: [{ type: 'text', text: body }], isError: true };
|
|
}
|
|
|
|
/**
|
|
* Turns a thrown error into a tool result the model can act on.
|
|
*
|
|
* The distinction that matters is 401 — an expired JWT is the one failure the
|
|
* user has to fix by hand, and it otherwise looks identical to "this course
|
|
* doesn't exist". 403 is separated out for the same reason: it means the
|
|
* account genuinely lacks access, not that the call was malformed.
|
|
*/
|
|
export function toToolError(error: unknown, action: string): CallToolResult {
|
|
return failure(describeFailure(error, action));
|
|
}
|
|
|
|
/**
|
|
* The same diagnosis for a resource read or a prompt, which fail as protocol
|
|
* errors rather than as tool results. A 404 carries the spec's not-found code,
|
|
* which is what lets a client drop a stale resource instead of retrying it.
|
|
*/
|
|
export function toProtocolError(error: unknown, action: string): ProtocolError {
|
|
if (error instanceof ProtocolError) return error;
|
|
const notFound = error instanceof SchulcloudApiError && error.status === 404;
|
|
return new ProtocolError(notFound ? RESOURCE_NOT_FOUND : ErrorCode.InternalError, describeFailure(error, action));
|
|
}
|
|
|
|
function describeFailure(error: unknown, action: string): string {
|
|
if (error instanceof SchulcloudApiError) {
|
|
if (error.isAuthFailure) {
|
|
return (
|
|
`Schulcloud rejected the token while trying to ${action} (HTTP 401).\n\n` +
|
|
`The server's Schulcloud token has expired or been logged out. The user has to log in in a ` +
|
|
`browser, copy the "jwt" cookie (DevTools → Application → Cookies) and hand it to the server ` +
|
|
`with \`schulcloud token set\` or on the server's /token page — no restart needed. See docs/AUTH.md.`
|
|
);
|
|
}
|
|
if (error.status === 403) {
|
|
return `No permission to ${action} (HTTP 403). This account cannot see that resource.`;
|
|
}
|
|
if (error.status === 404) {
|
|
return `Not found while trying to ${action} (HTTP 404). Check the id.`;
|
|
}
|
|
return `Failed to ${action}: ${error.message}`;
|
|
}
|
|
if (error instanceof Error && error.name === 'TimeoutError') {
|
|
return `Timed out trying to ${action}. The instance may be slow or unreachable.`;
|
|
}
|
|
return `Failed to ${action}: ${error instanceof Error ? error.message : String(error)}`;
|
|
}
|